Accessible modal dialogs and drawers: focus in, trapped, and back
Implement modals with native <dialog> or role=dialog + aria-modal, a visible title via aria-labelledby, deliberate initial focus, Tab trapping, Escape to close, background made inert, and focus returned to the trigger (or a logical successor).
Overview
What: Implementation rules for modal dialogs, side drawers/sheets, and confirmation dialogs in web apps.
Why: Dialogs are the most common source of keyboard traps and "lost focus" bugs: focus stays behind the overlay, Tab escapes into the page, or closing drops focus to the top of the document. Screen-reader users then lose their place entirely.
Use when: Content must block interaction with the page until dismissed (confirmations, short forms, command palette, mobile navigation drawer).
Do not use when: Users need to reference the page while working (record detail, long editing) — use a non-modal side panel or a full page instead. Don't mark something aria-modal unless outside content is truly inert.
Implementation
- Prefer native:
<dialog>withshowModal()gives top-layer rendering, inert background, and Escape handling.const ref = useRef<HTMLDialogElement>(null); const open = () => { lastFocus.current = document.activeElement as HTMLElement; ref.current?.showModal(); }; const close = () => { ref.current?.close(); lastFocus.current?.focus(); }; <dialog ref={ref} aria-labelledby="dlg-title" onCancel={close}> <h2 id="dlg-title">Revoke token?</h2> … </dialog>If building custom:
role="dialog"(oralertdialogfor urgent confirmations),aria-modal="true",aria-labelledby, setinerton the rest of the app, and implement Tab/Shift+Tab wrapping. - Initial focus:
- Short confirmation: the least destructive button.
- Form: first field.
- Long content: the heading or a static element with
tabindex="-1"at the top so the screen reader starts at the beginning.
- Close: Escape, a visible close button (accessible name "Close"), and Cancel. Clicking the backdrop may close non-destructive dialogs only; never for forms with input (data loss).
- Return focus to the invoking element; if it no longer exists (row deleted), focus the next logical element (next row or list heading).
- Scroll: lock background scroll; the dialog body scrolls internally with
max-height: calc(100vh - 64px); max-height: calc(100dvh - 64px);(the vh line is the fallback for engines without dynamic units). Dialogs are the one placedvhfits, because they must track the visible area as toolbars move. - Sizes: widths 400px (confirm), 560–640px (form), full-screen sheet below 600px viewport.
- Drawers follow the same rules; slide open in 200–300ms and close in 150–250ms (NN/g's modal timing); under reduced motion, open instantly or with an opacity-only fade ≤ 150ms.
- One at a time: don't open a dialog from a dialog; replace content or navigate to a page (see modal-stacking anti-pattern).
Contract details from the APG and library practice:
aria-modalis a promise. Set it only when Tab/Shift+Tab really wrap inside the dialog and the background is inert/obscured; a false claim is worse than none for screen-reader users.- Initial focus, four cases: simple content → first focusable; structured content (lists, tables, several paragraphs) → a static element at the start with
tabindex="-1"and noaria-describedby; large scrollable content → the title; irreversible action → the least destructive button (RadixAlertDialogdoes this by default). aria-describedbyonly for a short flat description; omit it when the body has structure.- The Popover API is never modal — use
<dialog>+showModal()(or<dialog popover>) for true modality; a popover with a backdrop is not a dialog. Re-checkclosedby(light-dismiss control) support per browser before relying on it. - Focus must land somewhere on close: the trigger, or the next logical element when the trigger is gone; never
<body>. - Drawers and sheets are dialogs with edge positioning; they get the full contract.
Verification
- Opening moves focus inside; Tab and Shift+Tab never reach the page behind.
- Escape closes (unless a destructive in-progress state requires confirmation); a visible close control exists.
- Dialog has an accessible name from its visible heading.
- Background content is inert and not announced by screen readers while open.
- Closing returns focus to the trigger or a logical successor.
- Backdrop click doesn't discard entered form data.
- Dialog is usable at 320px width and 200% zoom with internal scroll;
max-heightdeclares avhfallback before thedvhvalue. - Tested with VoiceOver (Safari) and NVDA (Firefox/Chrome).
- If
aria-modal="true"is present, an automated test confirms Tab wraps inside the dialog and background elements are inert. - Destructive confirmations focus the safe action on open.
- After close,
document.activeElementis the trigger or a documented alternative, neverbody.
Sources:
Limitations
<dialog>is broadly supported in current browsers, but older embedded webviews may need a polyfill or custom implementation.inerthas good modern support; verify in your target browsers. Dynamic viewport units need Chrome 108, Safari 15.4 or Firefox 101 and later, hence the fallback.- Values verified 2026-09-26: 8 checked, of which 5 against standards (WCAG, WAI-ARIA APG), 2 against platform docs and 1 against published guidelines. Dialog widths are judgement defaults informed by Material's 280–560dp range.
- Full-screen modals on mobile can confuse the Back button; consider routing (URL change) for large sheets so Back closes them.