Free guideEditorial review in progress; your agent sees whether each guide's current version is reviewed. What “reviewed” means

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).

Discipline
Web apps
Type
Pattern
Platforms
Web app, Desktop
Version
1.2.0 · 2026-09-26

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

  1. Prefer native: <dialog> with showModal() 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" (or alertdialog for urgent confirmations), aria-modal="true", aria-labelledby, set inert on the rest of the app, and implement Tab/Shift+Tab wrapping.

  2. 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.
  3. 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).
  4. Return focus to the invoking element; if it no longer exists (row deleted), focus the next logical element (next row or list heading).
  5. 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 place dvh fits, because they must track the visible area as toolbars move.
  6. Sizes: widths 400px (confirm), 560–640px (form), full-screen sheet below 600px viewport.
  7. 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.
  8. 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-modal is 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 no aria-describedby; large scrollable content → the title; irreversible action → the least destructive button (Radix AlertDialog does this by default).
  • aria-describedby only 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-check closedby (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-height declares a vh fallback before the dvh value.
  • 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.activeElement is the trigger or a documented alternative, never body.

Sources:

Limitations

  • <dialog> is broadly supported in current browsers, but older embedded webviews may need a polyfill or custom implementation.
  • inert has 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.