Modal dialog
Dialog · focus trap + Escape + focus return
A dialog that takes over the page until dismissed. The pattern where a broken implementation strands keyboard users most visibly — focus trap, Escape, and returning focus are all things static markup cannot promise.
Live example
Real and interactive — use it with the mouse, or Tab to it and use the keys below.
Keyboard
| Key | Action |
|---|---|
| Tab / Shift + Tab | cycles through the focusable elements inside the dialog only — focus is trapped |
| Escape | closes the dialog |
Screen reader
- On open, focus moves into the dialog and its accessible name (the title) is announced.
- aria-modal=“true” tells the screen reader to treat everything outside the dialog as unavailable.
ARIA notes
- role=“dialog” with aria-modal=“true” and aria-labelledby pointing at the visible title.
- Focus is moved into the dialog on open and returned to the triggering element on every close path.
- Focus is trapped: Tab and Shift+Tab wrap within the dialog and never reach the page behind it.
Code
The real source of the example above — copy it and it works. This is the file that renders on this page, so the code and the live example can never drift apart.
import { useEffect, useId, useRef, type ReactNode } from 'react'
// Accessible modal dialog — the WAI-ARIA Dialog (Modal) pattern.
//
// Controlled by `open` / `onClose`. When open it renders role="dialog" with
// aria-modal="true" and an aria-labelledby pointing at its title. Three things
// make it actually usable, and all three are things static markup cannot give
// you — they are verified live with Playwright, not just by reading the code:
//
// 1. Focus moves into the dialog on open (to the first focusable element).
// 2. Focus is trapped: Tab and Shift+Tab cycle within the dialog and never
// reach the page behind it.
// 3. On close — whether by the close button, the Escape key, or a backdrop
// click — focus returns to the element that opened the dialog.
//
// The keydown listener is attached in the capture phase so Escape and the Tab
// trap win even if something inside the dialog also handles those keys.
const FOCUSABLE =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
export function Modal({
open,
onClose,
title,
children,
}: {
open: boolean
onClose: () => void
title: string
children: ReactNode
}) {
const titleId = useId()
const dialogRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
// The element that had focus when the dialog opened — the trigger. Captured
// here (not from a prop) so focus returns to whatever actually opened it.
const previouslyFocused = document.activeElement as HTMLElement | null
const dialog = dialogRef.current
const focusables = () => Array.from(dialog?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [])
focusables()[0]?.focus()
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
return
}
if (e.key !== 'Tab') return
const items = focusables()
if (items.length === 0) {
e.preventDefault()
return
}
const first = items[0]
const last = items[items.length - 1]
const active = document.activeElement
// Wrap at both ends, and pull focus back in if it has somehow escaped.
if (e.shiftKey && (active === first || !dialog?.contains(active))) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && (active === last || !dialog?.contains(active))) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKeyDown, true)
return () => {
document.removeEventListener('keydown', onKeyDown, true)
// Restore focus to the trigger on any close path (button, Escape, backdrop).
previouslyFocused?.focus?.()
}
}, [open, onClose])
if (!open) return null
return (
<div
className="modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={(e) => {
// Backdrop click closes; clicks inside the dialog do not bubble here.
if (e.target === e.currentTarget) onClose()
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="modal-panel w-full max-w-md rounded-xl border border-outline-variant bg-surface-container-low p-6 shadow-lg"
>
<div className="flex items-start justify-between gap-4">
<h2 id={titleId} className="text-lg font-semibold text-[color:var(--color-on-surface)]">
{title}
</h2>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1 text-on-surface-variant hover:bg-surface-container hover:text-on-surface"
aria-label="Close dialog"
>
<svg viewBox="0 0 12 12" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M3 3l6 6M9 3l-6 6" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="mt-3 text-sm text-on-surface-variant">{children}</div>
</div>
</div>
)
}
Accessibility pitfalls
AvoidFocus stays on the page behind the dialog, so Tab walks into hidden content.
DoMove focus into the dialog on open and trap it there.
AvoidClosing the dialog drops focus to the top of the page.
DoReturn focus to the element that opened the dialog.
AvoidNo Escape handler — the dialog can only be closed with the mouse.
DoClose on Escape as well as the close button.
Avoidaria-modal=“true” with no real focus constraint.
Doaria-modal is a promise to assistive tech — back it with an actual focus trap.