QuestionsAccessibility

Building an accessible modal dialog

Dialogs & Focus TrapHardAccessibility

What does a modal dialog need to be accessible? Walk through the focus trap, aria-modal, and restoring focus.

What it tests

The full modal contract: role, labelling, focus trap, Escape, background inert, and focus restoration.

Approach & answer

An accessible modal has a precise contract. (1) SEMANTICS: role="dialog" (or role="alertdialog" for a confirm/error that demands a response) plus aria-modal="true", and a name via aria-labelledby pointing at the dialog title (aria-describedby for the body if useful). (2) FOCUS IN: when it opens, move focus INTO the dialog — to the first focusable control, or the dialog container/heading if there isn't an obvious one. (3) FOCUS TRAP: while open, Tab and Shift+Tab must cycle only within the dialog; Tab from the last element wraps to the first and vice versa, so focus can't escape to the page behind. (4) BACKGROUND INERT: content behind the dialog must be unreachable AND unread — set inert (or aria-hidden="true") on the rest of the page so screen-reader virtual-cursor browsing and Tab both stay contained; aria-modal helps but the inert background is what actually prevents 'reading behind'. (5) ESCAPE + dismiss: Escape closes it; clicking the backdrop typically closes (but not for alertdialog). (6) FOCUS RESTORATION: on close, return focus to the element that opened it (store it on open) — otherwise the user is dumped at the top of the document. The modern shortcut is the native <dialog> element with showModal(), which gives you the top layer, a real backdrop (::backdrop), Escape-to-close, and background inertness for free — you still supply the label and focus restoration, but it removes most of the trap boilerplate and its bugs. Rolling your own is where teams get it wrong: forgetting the trap, forgetting to restore focus, or hiding the background from sighted users (CSS) but not from AT.

Use this technique when

Building any modal/overlay; auditing one for trap, inert background, Escape, and focus restore.

Code

function Modal({ open, onClose, title, children }) {
  const ref = React.useRef(null);
  const opener = React.useRef(null);

  React.useEffect(() => {
    if (!open) return;
    opener.current = document.activeElement;          // remember what to restore
    const dlg = ref.current;
    dlg.showModal();                                   // native: top layer + backdrop + inert bg + Esc
    const first = dlg.querySelector('button, [href], input, select, textarea, [tabindex]');
    (first || dlg).focus();
    return () => { dlg.close(); opener.current && opener.current.focus(); }; // restore focus on close
  }, [open]);

  return (
    <dialog ref={ref} aria-labelledby="dlg-title" onCancel={onClose} onClose={onClose}>
      <h2 id="dlg-title">{title}</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </dialog>
  );
}

References