Skip to content

Find what you need, instantly

Search components, ARIA roles & attributes, and WCAG criteria.

OverlaysFlagship guide

Dialog (Modal)

A window overlaid on the page, blocking interaction with the rest of the app until dismissed.

Last verified against WCAG 2.2 and WAI-ARIA APG 1.2 on 2026-07-02.

Implementation

Focus trap via Tab/Shift+Tab interception, Escape handling, and explicit focus restoration.

dialog-pattern.tsx
function DialogPattern({ triggerLabel, title, children }) {
  const [open, setOpen] = useState(false);
  const dialogRef = useRef(null);
  const closeBtnRef = useRef(null);
  // Remember what had focus before opening so we can restore it on close.
  const triggerRef = useRef(null);

  useEffect(() => {
    if (!open) return;
    closeBtnRef.current?.focus(); // move focus INTO the dialog on open

    function onKeyDown(e) {
      if (e.key === "Escape") { setOpen(false); return; }
      if (e.key !== "Tab") return;

      // Manual focus trap: cycle Tab/Shift+Tab within the dialog so focus
      // can never reach the inert page behind it.
      const focusables = dialogRef.current.querySelectorAll(
        'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
      );
      const first = focusables[0], last = focusables[focusables.length - 1];
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault(); last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault(); first.focus();
      }
    }
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [open]);

  function close() {
    setOpen(false);
    triggerRef.current?.focus(); // restore focus to the trigger
  }

  return (
    <>
      <button ref={triggerRef} onClick={() => setOpen(true)}>
        {triggerLabel}
      </button>
      {open && (
        <div className="overlay" onMouseDown={(e) => {
          if (e.target === e.currentTarget) close();
        }}>
          <div
            ref={dialogRef}
            role="dialog"
            aria-modal="true"
            aria-labelledby="dialog-title"
          >
            <h2 id="dialog-title">{title}</h2>
            <button ref={closeBtnRef} onClick={close} aria-label="Close">×</button>
            {children}
          </div>
        </div>
      )}
    </>
  );
}

Live demo

Required roles, states & properties

Required roles, states, and properties
ElementAttributeWhy
Dialog containerrole="dialog"Identifies the element as a dialog so AT announces "dialog" and switches some screen readers into a more linear reading mode.
Dialog containeraria-modal="true"Signals that content outside the dialog is inert. Combined with — not a replacement for — a real focus trap.
Dialog containeraria-labelledbyPoints at the visible heading so the dialog's accessible name matches what's on screen (SC 2.5.3 Label in Name).
Dialog containeraria-describedbyOptionally points at supporting body text so it's announced right after the name/role on open.
Close buttonaria-label="Close dialog"An icon-only close button has no accessible name from its text content, so one must be supplied explicitly.

Keyboard interaction model

Keyboard interaction model
KeyBehavior
TabMoves focus to the next focusable element inside the dialog. Wraps from the last to the first (focus trap).
Shift+TabMoves focus to the previous focusable element inside the dialog. Wraps from the first to the last.
EscapeCloses the dialog and returns focus to the triggering element.
Enter / SpaceActivates the focused button (native behavior, no custom handling needed).

Focus management rules

  • On open: focus moves to the first focusable element inside the dialog (here, the close button).
  • While open: Tab/Shift+Tab cycle only within the dialog's focusable elements.
  • On close (Escape, confirm, cancel, or scrim click): focus returns to the element that opened the dialog.
  • If the trigger element no longer exists after close (e.g. it was in a list row that got deleted), move focus to the next logical element — never let it fall back to <body>.

WCAG 2.2 success criteria mapping

WCAG 2.2 success criteria that apply to this component
SCNameLevelWhy it applies
2.1.1KeyboardAAll functionality is operable through a keyboard interface with no specific timing.
2.1.2No Keyboard TrapAKeyboard focus can always be moved away from a component using standard navigation.
2.4.3Focus OrderAFocusable components receive focus in an order that preserves meaning and operability.
2.4.7Focus VisibleAAAny keyboard-operable UI has a visible focus indicator.
4.1.2Name, Role, ValueAFor all UI components, name, role, and value are programmatically determinable; states and changes are announced.

References