Accessibility · Accessible components
Accessible dialogs / modals
⚡ TL;DR — An accessible dialog is a small contract: it must announce itself as a dialog with a name, move focus in, keep focus in while modal, close on
Esc, and hand focus back to the trigger. Reach for native<dialog>+showModal()first; it satisfies most of the contract at the platform level, and everything else you bolt on is you re-implementing the browser.
🧠 Mental model
Think in terms of what a screen-reader user experiences, not what a sighted user sees. A sighted user perceives a modal instantly: a dimmed backdrop, a floating card. A screen-reader user perceives nothing unless the accessibility tree tells them. So a dialog is fundamentally an announcement problem before it is a focus problem: when it opens, assistive tech must say "dialog, Edit profile" — that's the role plus the accessible name — and then the rest of the page must vanish from their reach so they don't wander behind it.
The word "modal" is the whole crux. Modal means the background is off-limits until you deal with the dialog. That off-limits state is not styling; it's aria-modal="true" for the AT hint and real inertness (inert / showModal()) for actual containment. Get modality right and the dialog is trivial; get it wrong and every other detail is polish on a broken foundation.
⚙️ How it actually works
The APG dialog pattern is five obligations:
| Obligation | Native <dialog> |
Hand-rolled |
|---|---|---|
| Role + name | role="dialog" implicit; name via aria-labelledby |
add both yourself |
| Focus moves in | showModal() focuses first focusable / autofocus |
.focus() on open |
| Focus stays in | background made inert automatically |
inert siblings + wrap Tab |
Esc closes |
fires cancel, then close |
key handler |
| Focus returns to trigger | not guaranteed — do it yourself | stash & restore activeElement |
Two mechanisms are doing the heavy lifting. First, the top layer: showModal() promotes the dialog above the entire stacking context, so it escapes z-index wars and overflow: hidden clipping — a real bug class that hand-rolled overlays fight forever. Second, inertness: the browser prunes the rest of the document from focus, hit-testing, and the accessibility tree in one move. aria-modal="true" alone does none of that — it only whispers to AT "treat the background as out of scope." It does not trap focus. Believing it does is the single most common misconception here.
Choose the role deliberately: role="dialog" for interactive tasks (a form), role="alertdialog" for an urgent decision that interrupts (a destructive-action confirm), because alertdialog makes AT announce the body text immediately.
💻 Code
The native path is the recommended implementation, not a shortcut:
<button id="open">Edit profile</button>
<dialog id="dlg" aria-labelledby="dlg-title">
<h2 id="dlg-title">Edit profile</h2> <!-- the accessible NAME -->
<form method="dialog">
<label>Name <input name="name" autofocus /></label>
<button value="cancel">Cancel</button>
<button value="save">Save</button>
</form>
</dialog>
const dlg = document.getElementById('dlg');
let opener = null;
document.getElementById('open').addEventListener('click', (e) => {
opener = e.currentTarget; // remember the trigger
dlg.showModal(); // top layer + inert background + Esc + focus-in
});
// The clause the platform won't guarantee — always wire this:
dlg.addEventListener('close', () => opener?.focus());
<!-- ❌ A "modal" that is a lie to assistive tech -->
<div class="overlay">
<div class="card"> <!-- no role, no name, no focus mgmt -->
<h2>Edit profile</h2> <!-- SR users hear nothing on open -->
</div>
</div>
⚖️ Trade-offs
- Prefer
<dialog>over any focus-trap library. Headless kits andfocus-trapre-implement — imperfectly — what the browser now does correctly, and none can replicate top-layer rendering withz-index. role="dialog"needs a name or it's anonymous. A dialog with noaria-labelledby/aria-labelannounces as just "dialog," which is useless. The name is not optional.- When NOT to build a modal: if the task doesn't require blocking the page, don't. A non-modal popover, a toast, an inline expander — none should trap focus. Modality is a cost you impose on the user; charge it only when the flow genuinely can't continue otherwise.
alertdialogvsdialog: usealertdialogsparingly — it forces an interruption. A settings panel is adialog; "Delete this account?" is analertdialog.
💣 Gotchas interviewers probe
aria-modal="true"does not trap focus or inert the background. It's an AT hint only. Real containment isinert/showModal(). This is the trap question.- No accessible name.
role="dialog"withoutaria-labelledbyis a silent failure — validators pass, users are lost. - Focus not restored on close dumps the user on
<body>at the top of the page. Native<dialog>doesn't reliably restore it, so you must. - Initial focus on a destructive button. Auto-focusing "Delete" in a confirm dialog invites an accidental
Enter. Focus the safe/first field, or the heading withtabindex="-1". - Nested
<dialog>/inert— inerting the wrong subtree can trap the user outside the dialog. Verify the dialog itself stays reachable. - Scroll-locking is separate from focus trapping. Trapping
Tabdoesn't stop the background scrolling on wheel/touch — that's an extra concern. - Closing on backdrop click must not be the only way out — keyboard users can't click a backdrop;
Escis mandatory.
🎯 Say this in the interview
"I treat a dialog as a five-part contract: it announces itself as a dialog with an accessible name, moves focus in on open, keeps focus in while it's modal, closes on
Esc, and returns focus to the trigger on close. My default is native<dialog>withshowModal()because it gives me the top layer, makes the background genuinely inert, and handlesEscand focus-in correctly — all things hand-rolled modals get subtly wrong. The one clause it doesn't guarantee is restoring focus to the opener, so I always stash the trigger and refocus it onclose. The detail I'm careful to state is thataria-modalis only a hint to assistive tech — it does not trap focus or inert anything by itself;inertorshowModal()does the real work. And the name isn't optional:role=\"dialog\"with no label announces as just 'dialog.'"
🔗 Go deeper
- ARIA APG — Dialog (Modal) Pattern — the full keyboard, focus, and naming spec.
- MDN — <dialog> —
showModal, top layer,::backdrop,cancel/close. - MDN — inert — removing a subtree from focus and the a11y tree.
- web.dev — Building a dialog component — a production-grade walkthrough with edge cases.