Modal / Dialog — modality as responsibility

A dialog captures attention: dims the background, blocks the rest of the UI, and requires explicit dismissal. A focus-management mistake traps keyboard and screen-reader users.

When you need a modal

  • Confirming a destructive action.
  • A short scenario that needs an answer (a form with 2–4 fields).
  • Licenses / critical warnings.

When a modal is not better: long forms, complex navigation, content the user wants to compare with the page behind. Consider a Drawer, Popover, or a separate page.

Minimum ARIA contract

<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc"
>
  <h2 id="dialog-title">Delete course?</h2>
  <p id="dialog-desc">This action cannot be undone.</p>
  {/* actions */}
</div>

For a native approach in modern browsers — the <dialog> element and showModal() / close(): part of the behavior (top layer, Escape) is built in. A custom div modal requires a full manual implementation.

Focus trap

While the dialog is open:

  1. On open, store document.activeElement.
  2. Move focus into the dialog (first focusable, or a container with tabIndex={-1}).
  3. Tab / Shift+Tab cycle only among focusables inside.
  4. On close, return focus to the opener.

Without focus restore, the user “gets lost” at the start of the page.

Escape and backdrop

  • Escape closes the dialog (unless there is a blocking “required” scenario — those are rare and controversial).
  • Backdrop click often closes — document the behavior.
  • Do not close on clicks inside the content (stop propagation on the panel).

Scroll lock

On body while open: overflow: hidden (and scrollbar-width compensation so layout does not jump). iOS has quirks — test on a real device.

API structure

<Modal open={open} onOpenChange={setOpen}>
  <Modal.Content>
    <Modal.Header>
      <Modal.Title>Confirmation</Modal.Title>
      <Modal.Close aria-label="Close" />
    </Modal.Header>
    <Modal.Body>…</Modal.Body>
    <Modal.Footer>
      <Button variant="ghost" onClick={close}>Cancel</Button>
      <Button variant="destructive" onClick={confirm}>Delete</Button>
    </Modal.Footer>
  </Modal.Content>
</Modal>

It is wise to lean on proven primitives (Radix Dialog, React Aria) — focus trap and a11y are already battle-tested. In the DS you style them and constrain the API with product tokens.

Animation

Short fade/scale (150–200 ms). Do not block closing during the animation. Respect prefers-reduced-motion.

Practice

  1. Implement open/close, Escape, backdrop click, focus restore.
  2. Check the Tab cycle inside and focus return to the trigger button.
  3. Run axe + a manual VoiceOver/NVDA smoke test.
  4. Stories: default, with form, destructive confirm.