August 12, 2026 · 8 min
Build an accessible React modal from scratch
Portals, scroll locking, focus traps, and closing with Escape. The four things every dialog needs before you reach for a library.
- React
- TypeScript
- a11y
Try it outClick around. This is the real component.
Preview
Escape or overlay click closes it.
1"use client";2 3import { useEffect, useId, useRef } from "react";4import { createPortal } from "react-dom";5 6type ModalProps = {7 open: boolean;8 title: string;9 onClose: () => void;10 children: React.ReactNode;11};12 13const FOCUSABLE =14 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';15 16export function Modal({ open, title, onClose, children }: ModalProps) {17 const titleId = useId();18 const panelRef = useRef<HTMLDivElement>(null);19 const lastFocus = useRef<HTMLElement | null>(null);20 21 useEffect(() => {22 if (!open) return;23 24 lastFocus.current = document.activeElement as HTMLElement;25 const previousOverflow = document.body.style.overflow;26 document.body.style.overflow = "hidden";27 panelRef.current?.focus();28 29 const onKeyDown = (event: KeyboardEvent) => {30 if (event.key === "Escape") {31 onClose();32 return;33 }34 35 if (event.key !== "Tab" || !panelRef.current) return;36 37 const nodes = [38 ...panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE),39 ];40 if (nodes.length === 0) return;41 42 const first = nodes[0];43 const last = nodes[nodes.length - 1];44 const active = document.activeElement;45 46 if (event.shiftKey && active === first) {47 event.preventDefault();48 last.focus();49 } else if (!event.shiftKey && active === last) {50 event.preventDefault();51 first.focus();52 }53 };54 55 window.addEventListener("keydown", onKeyDown);56 return () => {57 window.removeEventListener("keydown", onKeyDown);58 document.body.style.overflow = previousOverflow;59 lastFocus.current?.focus();60 };61 }, [open, onClose]);62 63 if (!open) return null;64 65 return createPortal(66 <div className="fixed inset-0 z-50 grid place-items-center p-4">67 <button68 type="button"69 aria-label="Close dialog"70 className="absolute inset-0 bg-black/60"71 onClick={onClose}72 />73 <div74 ref={panelRef}75 role="dialog"76 aria-modal="true"77 aria-labelledby={titleId}78 tabIndex={-1}79 className="relative w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-6 shadow-2xl outline-none"80 >81 <div className="flex items-start justify-between gap-4">82 <h2 id={titleId} className="text-lg font-semibold">83 {title}84 </h2>85 <button86 type="button"87 onClick={onClose}88 className="rounded-md px-2 py-1 text-sm text-zinc-400 hover:text-white"89 >90 Close91 </button>92 </div>93 <div className="mt-4 text-sm text-zinc-300">{children}</div>94 </div>95 </div>,96 document.body,97 );98}A modal looks simple: overlay, panel, close button. The hard part is everything around it. If you skip the details, you lock scroll incorrectly, tab into the page behind the dialog, and leave keyboard users stranded.
This is the version I actually ship. A controlled open prop, a portal to document.body, and a focus trap that restores focus when the dialog closes.
What the component has to do
- Render above the rest of the tree with a portal, so stacking contexts don't bury it.
- Lock body scroll while it is open.
- Move focus into the dialog, trap Tab inside it, restore focus on close.
- Close on Escape and on overlay click.
- Expose the right ARIA:
role="dialog",aria-modal,aria-labelledby.
How to use it
1"use client";2 3import { useState } from "react";4import { Modal } from "./modal";5 6export function InviteButton() {7 const [open, setOpen] = useState(false);8 9 return (10 <>11 <button type="button" onClick={() => setOpen(true)}>12 Open modal13 </button>14 <Modal open={open} title="Invite a teammate" onClose={() => setOpen(false)}>15 <p>Focus stays inside this panel until you close it.</p>16 </Modal>17 </>18 );19}Why this is enough
Libraries like Radix and Headless UI exist because these details are easy to get wrong at scale. For a portfolio, a settings dialog, or a confirmation prompt, this pattern is the whole component. You own the markup, the motion, and the accessibility, which is the point of writing it yourself.