ESC

Type to search the knowledge base.

Accessible Modals Patterns

Modal dialogs that work: focus trap, Escape, return focus, aria-modal, and why native dialog or APG patterns beat div soup.

intermediate3 min read
  • accessibility
  • accessible-modals

Modals fail accessibility when focus leaks to the page behind, Escape does nothing, and the title isn’t announced. Treat a modal as a mode change: move focus in, trap while open, restore on close.

Docs: APG Dialog, HTML dialog.

Prefer native <dialog>

<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Delete file?</h2>
  <p>This cannot be undone.</p>
  <button type="button" id="cancel">Cancel</button>
  <button type="button" id="ok">Delete</button>
</dialog>
const dialog = document.getElementById('confirm');
const opener = document.getElementById('delete-btn');

opener.addEventListener('click', () => {
  dialog.showModal();
});

document.getElementById('cancel').addEventListener('click', () => {
  dialog.close();
});

dialog.addEventListener('close', () => {
  opener.focus();
});

showModal() provides a top layer and basic focus handling in modern browsers. Still label the dialog and restore focus.

Required behaviors (custom or native)

  1. Open: focus dialog container (tabindex="-1") or first focusable control.
  2. Tab/Shift+Tab: cycle inside the dialog.
  3. Escape: close (unless destructive workflow explicitly needs confirm — still usually allow Escape to cancel).
  4. Close: return focus to opener.
  5. Background: inert / non-interactive (aria-hidden or inert on the rest of the page when not using native modal).
<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="title"
  tabindex="-1"
>
  <h2 id="title">Settings</h2>
  …
</div>

Labeling

  • aria-labelledby → visible title
  • or aria-label if no visible title (prefer visible)
  • Optional aria-describedby for supporting text

Initial focus choice

Dialog type Initial focus
Confirm destructive Cancel (safer) or least destructive action
Form modal First field
Info only Close button or container

Scroll and background

Lock body scroll carefully; ensure focusable elements under the overlay aren’t reachable. Native modal dialog helps.

React note

Portals render dialogs at document.body. Manage focus in effects; cleanup on unmount. Libraries: Radix Dialog, React Aria — still verify.

Footguns

  1. role="dialog" without focus trap.
  2. Focus on close goes to body.
  3. Nested modals without stack discipline.
  4. Auto-opening modals on page load.
  5. Only click-outside to close — keyboard users need Escape.

Interview out-loud answer

“Modals need labeled dialog semantics, focus moved in, Tab trapped, Escape to close, and focus restored to the opener. I prefer native dialog showModal when possible. aria-modal documents the modality; focus control makes it real.”

Background inert

When not using showModal(), mark the rest of the page inert or aria-hidden carefully (don’t hide the dialog itself). Ensure assistive tech cannot read “through” the overlay. Test that Tab never lands on a footer link behind the mask.

Extra practice

Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.

Notes from real codebases

Teams that succeed here keep the rules mechanical: lint where possible, CI for the rest, and a short human checklist for what automation cannot see. Document exceptions with an owner name and a removal date so “temporary” escapes do not become permanent architecture.

Further reading

Related guides