Skip to content
All notes

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.

modal.tsx

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 <button
68 type="button"
69 aria-label="Close dialog"
70 className="absolute inset-0 bg-black/60"
71 onClick={onClose}
72 />
73 <div
74 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 <button
86 type="button"
87 onClick={onClose}
88 className="rounded-md px-2 py-1 text-sm text-zinc-400 hover:text-white"
89 >
90 Close
91 </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

example.tsx
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 modal
13 </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.