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:
- On open, store
document.activeElement. - Move focus into the dialog (first focusable, or a container with
tabIndex={-1}). - Tab / Shift+Tab cycle only among focusables inside.
- 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
- Implement open/close, Escape, backdrop click, focus restore.
- Check the Tab cycle inside and focus return to the trigger button.
- Run axe + a manual VoiceOver/NVDA smoke test.
- Stories: default, with form, destructive confirm.