Accessible modal dialog
A modal that doesn't break for keyboard users, screen readers, or anyone who hits Escape expecting it to close. The pattern AI tools get wrong most often, with the canonical fix.
Why this pattern keeps breaking
Modal dialogs are the most common custom widget on the web and the most common one to fail accessibility review. The visual pattern is straightforward — overlay, centred panel, close button. The behavioural contract is what trips up almost every implementation: focus has to move into the dialog, get trapped there until close, and return to the trigger when the dialog dismisses. Escape has to close it. The page behind has to be inert to assistive tech. Get any of those wrong and a keyboard or screen-reader user is stuck in a layer of UI they can't escape or understand.
How AI coding tools fail this
Ask any AI assistant for "a modal" and you will get a styled <div>
overlay with a close button. The visual is correct. The accessibility
contract — role="dialog", aria-modal="true", focus trap, focus
return, Escape handler, page-behind inert — is missing entirely or
partially implemented in a way that fails one of the corners.
The most common shipped failures:
- Focus stays on the triggering button when the modal opens, so a screen-reader user has no idea anything happened.
- Tab cycles out of the modal into the page behind, with no return.
- Escape closes the modal but focus jumps to
<body>instead of back to the triggering button — the user loses their place. - The page behind is visible to assistive tech, so screen-reader users can navigate "into" content they can't actually see or interact with.
- The overlay click closes the modal but Escape doesn't (or vice versa), creating an inconsistent dismissal contract.
Canonical implementation
The right answer for most product teams: use a vetted library. The WAI ARIA Authoring Practices Guide describes the contract; the leading React libraries implement it correctly. Radix UI Dialog (opens in new tab), Headless UI Dialog (opens in new tab), and React Aria Dialog (opens in new tab) all ship the focus management, ARIA attributes, and Escape handling correctly out of the box.
A minimal Radix-based dialog that meets the contract:
import * as Dialog from "@radix-ui/react-dialog";
export function ConfirmDelete({ onConfirm }: { onConfirm: () => void }) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button>Delete project</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed inset-0 m-auto h-fit max-w-sm rounded-lg bg-background p-6">
<Dialog.Title className="text-lg font-semibold">
Delete this project?
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-muted-foreground">
This cannot be undone. The project history will also be
removed.
</Dialog.Description>
<div className="mt-6 flex justify-end gap-3">
<Dialog.Close asChild>
<button>Cancel</button>
</Dialog.Close>
<button onClick={onConfirm}>Delete</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}This satisfies all the requirements: focus moves into the dialog on
open, traps inside, returns to the trigger on close. Escape closes.
role="dialog" and aria-modal="true" are applied automatically.
Dialog.Title is wired as the accessible name; Dialog.Description
as the description. The overlay is dismissible. The page behind is
inert to assistive tech via aria-hidden on siblings.
Verification checklist
Before shipping any dialog, manually verify each of these:
- Tab into the trigger, press Enter — focus moves into the dialog.
- Tab repeatedly inside the dialog — focus cycles through dialog controls only, never reaching the page behind.
- Press Escape — dialog closes, focus returns to the original trigger.
- Click the overlay — dialog closes, focus returns to the trigger.
- With a screen reader running, open the dialog — the dialog title is announced, and only dialog content is reachable.
- Resize the viewport to mobile width — the dialog remains scrollable and dismissible.