Trap Focus Correctly in a Modal Dialog
Move focus in on open, cycle Tab inside the dialog, return focus on close, and handle background scroll and screen-reader announcement.
Task
Build a dialog whose focus behavior is correct for keyboard and screen-reader users, verified with a scripted tab walkthrough.
When to use this
The custom modal looks fine but Tab escapes to the page behind it, focus is left on the trigger when reopened, and screen-reader users never hear that a dialog opened.
Prerequisites
- A modal component in your own code (or willingness to use <dialog>)
- A keyboard for manual testing
- At least one screen reader (NVDA, VoiceOver) for verification
Steps
1. Prefer the native dialog element before building machinery.
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete this dataset?</h2>
<p>This cannot be undone.</p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="confirm" class="danger">Delete</button>
</form>
</dialog>
<script>
const dlg = document.getElementById('confirm');
// showModal: focus in, backdrop, Esc-to-close, top-layer rendering
document.querySelector('[data-open-confirm]').addEventListener('click', () => dlg.showModal());
dlg.addEventListener('close', () => {
// return focus to the trigger: native dialog does NOT do this
document.querySelector('[data-open-confirm]').focus();
});
</script>
Expected output: showModal moves focus to the first focusable child, renders above everything, and Escape closes it, leaving only focus-return as your job.
2. For custom modals, implement the trap with a walker.
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]),
select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function trapFocus(dlg: HTMLElement, ev: KeyboardEvent) {
if (ev.key !== 'Tab') return;
const items = [...dlg.querySelectorAll<HTMLElement>(FOCUSABLE)]
.filter((el) => el.offsetParent !== null); // visible only
if (!items.length) return;
const first = items[0], last = items[items.length - 1];
const active = dlg.contains(document.activeElement)
? document.activeElement as HTMLElement : first;
if (ev.shiftKey && document.activeElement === first) {
ev.preventDefault(); last.focus(); // shift-tab on first -> last
} else if (!ev.shiftKey && document.activeElement === last) {
ev.preventDefault(); first.focus(); // tab on last -> first
} else if (!dlg.contains(document.activeElement)) {
ev.preventDefault(); first.focus(); // focus escaped -> reclaim
}
}
Expected output: Tab cycles inside the dialog forever, shift-tab wraps backward, and a focus that somehow escaped is reclaimed on the next keypress.
3. Manage open, close, and background scroll together.
function openDialog(dlg: HTMLElement, trigger: HTMLElement) {
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden'; // freeze background scroll
dlg.showModal ? dlg.showModal() : dlg.setAttribute('open', '');
(dlg.querySelector<HTMLElement>(FOCUSABLE) ?? dlg).focus();
const restore = () => {
document.body.style.overflow = prevOverflow; // unfreeze
trigger.focus(); // return focus to opener
};
dlg.addEventListener('close', restore, { once: true });
dlg.addEventListener('cancel', (e) => { /* Esc: allow, or confirm-first */ }, { once: true });
}
Expected output: Opening freezes the page behind, closing restores scroll and lands focus back on the trigger, so keyboard users resume exactly where they were.
4. Announce the dialog to assistive technology.
<div role="dialog" aria-modal="true" aria-labelledby="dlg-title" aria-describedby="dlg-desc">
<h2 id="dlg-title">Delete this dataset?</h2>
<p id="dlg-desc">This action cannot be undone. Type the dataset name to confirm.</p>
<!-- ... -->
</div>
<!-- or announce imperatively if the dialog lacks a title element -->
<div role="status" aria-live="assertive" class="sr-only" id="dlg-announce"></div>
<script>
document.getElementById('dlg-announce').textContent = 'Confirm deletion dialog opened';
</script>
Expected output: Screen readers announce the dialog title and description when it opens; aria-modal tells them the rest of the page is inert.
5. Verify with a keyboard-only scripted walkthrough.
echo "1) focus trigger, Enter -> dialog opens, focus on first button"; echo "2) Tab x5 -> focus cycles inside dialog only (check document.activeElement)"; echo "3) Shift+Tab -> wraps backward to last control"; echo "4) Esc -> closes, focus returns to trigger"; echo "5) reload, run NVDA/VoiceOver: dialog open announces title+desc"Expected output: Each step behaves as scripted: focus enters on open, never escapes while open, returns on close, and the screen reader announces the dialog name and description.
Troubleshooting
Problem
Focus escapes to browser UI (address bar)
Solution
Tabbing past the last item reaches browser chrome before your keydown handler sees it; the wrap must preventDefault on the last element itself, and shift-tab on the first, which the walker does.
Problem
A dynamic control appears inside the dialog and breaks the cycle
Solution
The trap queries focusable items on every keypress, so newly added buttons join automatically; stale item lists are the classic bug when the query is cached on open.
Problem
Screen reader still reads background content
Solution
aria-modal plus inert on the background is the robust pair; set inert on the page container while the dialog is open so the accessibility tree itself excludes the background.
Limitations
- Focus trapping is one third of modal accessibility; contrast, focus visibility, and reduced-motion also gate WCAG conformance.
- The native dialog element handles most of this; custom implementations must re-earn every behavior they chose to bypass.
A modal that leaks focus is a broken modal
Tab escaping behind an overlay is a keyboard dead end: the user is typing into an invisible page. Focus in on open, cycle inside, return on close, and announce the dialog. The native dialog element provides most of it, and a nine-line walker covers the rest.
keyboard walkthrough (the only honest test):
1. Enter on trigger -> dialog opens, focus = first button
2. Tab Tab Tab Tab -> focus cycles btn1 -> btn2 -> input -> btn1
(document.activeElement NEVER leaves the dialog)
3. Shift+Tab from btn1 -> focus = last control (wraps backward)
4. Esc -> dialog closes, focus = trigger button
5. NVDA announces: -> "Confirm deletion dialog, This action cannot..."
anti-patterns found in the wild:
- trap list cached at open (dynamic controls break it)
- focus left on body when closing (keyboard users are lost)
- overlay div without aria-modal (screen readers read background)
Return of focus is the most-forgotten rule
Closing a dialog and leaving focus on the document body strands keyboard users at the top of the page, forced to re-navigate everything. Native dialog does not restore focus for you; the close handler focusing the trigger is one line and the difference between usable and punishing.
inert beats z-index for background exclusion
Hiding content visually does not remove it from the accessibility tree; screen readers happily read a page that is painted behind an overlay. Setting inert on the background container, or relying on aria-modal plus native top-layer, removes it from the tree entirely, which is what modal actually means to assistive technology.