Accessibility · ARIA & interaction
Focus trapping (modals)
⚡ TL;DR — A modal must capture focus: while it's open,
Tabcycles only inside it, the background is inert,Esccloses it, and on close focus returns to the element that opened it. The native<dialog>element withshowModal()gives you all of that for free — reach for a hand-rolled trap only when you can't use it.
🧠 Mental model
"Modal" means the rest of the page is temporarily off-limits. For a mouse user, the dimmed backdrop communicates that. For a keyboard user there is no backdrop they can perceive — the only thing that enforces "you're stuck here until you deal with this" is the focus trap. Without it, Tab from the last button in the dialog walks straight out into the page behind, and the user is now filling in a form they can't see, with the modal still floating on top. To them the app has simply broken.
So a modal is really a contract with four clauses: focus goes in on open, focus stays in while open (Tab wraps, background can't be reached), Esc gets out, and focus comes back to the trigger on close. Miss the last clause and you dump the user on <body> — the modal was a detour with no road home.
⚙️ How it actually works
Use the platform. <dialog>'s showModal() implements the entire contract natively:
- It renders in the top layer, above everything, escaping
z-indexandoverflow: hiddenstacking bugs entirely. - It makes the rest of the document inert — background content is unfocusable, unclickable, and pruned from the tab loop. This is the real focus trap, enforced by the browser.
Esccloses it and fires acancelevent.- It moves initial focus inside (to the first focusable element, or an element you mark
autofocus). ::backdropgives you the overlay with no extra element.
What it does not do reliably across engines is restore focus to the trigger — so you still capture and restore that yourself. And showModal() (modal) differs from show() (non-modal): only the modal form traps focus and inerts the background.
If you must hand-roll (legacy support, a framework portal that fights <dialog>), you re-create each clause:
- On open, record
document.activeElementas the return target, then focus the first element inside. - Mark everything else inert — the
inertattribute on sibling content is the modern, correct tool; it removes a whole subtree from focus, hit-testing, and the a11y tree in one attribute. - Intercept
Tab/Shift+Tabat the boundaries and wrap: from the last elementTab→ first; from the first,Shift+Tab→ last. - Handle
Esc. On close,.focus()the stored trigger. - Add
role="dialog"+aria-modal="true"and an accessible name viaaria-labelledbypointing at the title.
💻 Code
The native path — this is the recommended implementation:
<button id="open">Edit profile</button>
<dialog id="dlg" aria-labelledby="dlg-title">
<h2 id="dlg-title">Edit profile</h2>
<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 who opened it
dlg.showModal(); // top layer + background inert + Esc + focus-in
});
// The one clause the platform doesn't guarantee: return focus.
dlg.addEventListener('close', () => opener?.focus());
The manual trap, if <dialog> is off the table:
function trap(container, e) {
if (e.key !== 'Tab') return;
const f = container.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus(); e.preventDefault(); // wrap backwards
} else if (!e.shiftKey && document.activeElement === last) {
first.focus(); e.preventDefault(); // wrap forwards
}
}
// Plus: mark the rest of the page `inert`, handle Esc, restore focus on close.
⚖️ Trade-offs
- Prefer
<dialog>over any library.focus-trap, headless UI kits, and custom loops all re-implement — imperfectly — what the browser now does natively and correctly, including the top-layer rendering you can't replicate withz-index. aria-modal="true"is a hint, not an enforcer. It tells AT the background is out of scope, but it does not actually trap focus or make the background inert. You still needinert/showModal()for real containment. Believingaria-modaltraps focus is a classic misconception.- When NOT to trap: non-modal surfaces — a toast, a non-modal popover, an autocomplete listbox. Trapping focus in something the user should be able to
Tabpast turns a helper into a cage. Modality is the deciding question.
💣 Gotchas interviewers probe
- Forgetting to restore focus is the most common bug: the modal closes and the user is on
<body>, back at the top. Always stash the opener and refocus it. aria-modaldoes not trap focus. Onlyinert/showModal()inerts the background. Know the difference cold.- Initial focus placement matters. Focus the first interactive element, or the dialog/heading — not a destructive button, and not nothing. Auto-focusing "Delete" invites disaster.
- Old
aria-hidden-the-background trick is fragile — you must remember to un-hide on close, and it doesn't stop clicks.inertsupersedes it. - A dialog with no focusable children breaks a manual trap (nothing to focus). Give the dialog itself
tabindex="-1"and focus that. Escmust close. Keyboard users expect it; omitting it is a WCAG-adjacent failure and just feels broken.- Scroll-locking the background is a separate concern from focus — trapping focus doesn't stop the page behind from scrolling.
🎯 Say this in the interview
"A modal is a contract with keyboard users: focus moves in on open, stays trapped inside while it's open,
Esccloses it, and focus returns to the trigger afterwards. The backdrop only communicates modality to sighted users — the focus trap is what actually enforces it for everyone else. My default is the native<dialog>withshowModal(), because it gives me the top layer, makes the background genuinely inert, handlesEsc, and moves focus in — all correctly, which hand-rolled traps rarely are. The one thing I still wire up is restoring focus to the opener on close, since that isn't guaranteed. If I can't use<dialog>, I re-create each clause manually: record the active element,inertthe rest of the page, wrapTabat the boundaries, and refocus the trigger on close. And I'm clear thataria-modalis only a hint — it doesn't trap focus by itself."
🔗 Go deeper
- ARIA APG — Dialog (Modal) Pattern — the full keyboard and focus 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.