OverlaysFlagship guide
Dialog (Modal)
A window overlaid on the page, blocking interaction with the rest of the app until dismissed.
Last verified against WCAG 2.2 and WAI-ARIA APG 1.2 on 2026-07-02.
Implementation
Focus trap via Tab/Shift+Tab interception, Escape handling, and explicit focus restoration.
dialog-pattern.tsx
function DialogPattern({ triggerLabel, title, children }) {
const [open, setOpen] = useState(false);
const dialogRef = useRef(null);
const closeBtnRef = useRef(null);
// Remember what had focus before opening so we can restore it on close.
const triggerRef = useRef(null);
useEffect(() => {
if (!open) return;
closeBtnRef.current?.focus(); // move focus INTO the dialog on open
function onKeyDown(e) {
if (e.key === "Escape") { setOpen(false); return; }
if (e.key !== "Tab") return;
// Manual focus trap: cycle Tab/Shift+Tab within the dialog so focus
// can never reach the inert page behind it.
const focusables = dialogRef.current.querySelectorAll(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0], last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);
function close() {
setOpen(false);
triggerRef.current?.focus(); // restore focus to the trigger
}
return (
<>
<button ref={triggerRef} onClick={() => setOpen(true)}>
{triggerLabel}
</button>
{open && (
<div className="overlay" onMouseDown={(e) => {
if (e.target === e.currentTarget) close();
}}>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
>
<h2 id="dialog-title">{title}</h2>
<button ref={closeBtnRef} onClick={close} aria-label="Close">×</button>
{children}
</div>
</div>
)}
</>
);
}Live demo
Required roles, states & properties
| Element | Attribute | Why |
|---|---|---|
| Dialog container | role="dialog" | Identifies the element as a dialog so AT announces "dialog" and switches some screen readers into a more linear reading mode. |
| Dialog container | aria-modal="true" | Signals that content outside the dialog is inert. Combined with — not a replacement for — a real focus trap. |
| Dialog container | aria-labelledby | Points at the visible heading so the dialog's accessible name matches what's on screen (SC 2.5.3 Label in Name). |
| Dialog container | aria-describedby | Optionally points at supporting body text so it's announced right after the name/role on open. |
| Close button | aria-label="Close dialog" | An icon-only close button has no accessible name from its text content, so one must be supplied explicitly. |
Keyboard interaction model
| Key | Behavior |
|---|---|
| Tab | Moves focus to the next focusable element inside the dialog. Wraps from the last to the first (focus trap). |
| Shift+Tab | Moves focus to the previous focusable element inside the dialog. Wraps from the first to the last. |
| Escape | Closes the dialog and returns focus to the triggering element. |
| Enter / Space | Activates the focused button (native behavior, no custom handling needed). |
Focus management rules
- On open: focus moves to the first focusable element inside the dialog (here, the close button).
- While open: Tab/Shift+Tab cycle only within the dialog's focusable elements.
- On close (Escape, confirm, cancel, or scrim click): focus returns to the element that opened the dialog.
- If the trigger element no longer exists after close (e.g. it was in a list row that got deleted), move focus to the next logical element — never let it fall back to <body>.
WCAG 2.2 success criteria mapping
| SC | Name | Level | Why it applies |
|---|---|---|---|
| 2.1.1 | Keyboard | A | All functionality is operable through a keyboard interface with no specific timing. |
| 2.1.2 | No Keyboard Trap | A | Keyboard focus can always be moved away from a component using standard navigation. |
| 2.4.3 | Focus Order | A | Focusable components receive focus in an order that preserves meaning and operability. |
| 2.4.7 | Focus Visible | AA | Any keyboard-operable UI has a visible focus indicator. |
| 4.1.2 | Name, Role, Value | A | For all UI components, name, role, and value are programmatically determinable; states and changes are announced. |