Fixing Broken Focus Traps in Modals That Let Keyboard Users Tab Out to the Page Behind
A modal looks completely correct — overlay dimmed, content centered — and a mouse user notices nothing wrong at all. But a keyboard-only user pressing Tab can walk focus straight out of the modal and onto a link or button in the page behind it, invisible and covered, yet still perfectly reachable.
The Problem
A modal dialog opens and looks completely correct — the overlay dims the background, the modal content is centered and visible, and clicking anywhere outside it does what's expected. But a user navigating purely with the keyboard, pressing Tab repeatedly, can walk focus right out of the modal and onto an interactive element in the page behind it — a nav link, a button under the overlay — that's visually covered and inaccessible to a mouse but still perfectly reachable by keyboard. Shift+Tab from the modal's first focusable element often escapes the same way in reverse. Nothing about this is visible to anyone testing with a mouse.
Why It Happens
A visual overlay doesn't change the DOM's tab order
A modal built as an absolutely or fixed-positioned overlay with a high z-index only controls what's visually on top — it does nothing to the underlying DOM structure. The browser's default Tab key behavior walks elements in document order (or explicit tabindex order), completely independent of visual stacking. An element sitting behind the modal, covered by the overlay, remains exactly as reachable by Tab as it was before the modal opened, unless something explicitly intervenes to change that.
aria-hidden alone doesn't reliably block keyboard focus
A common partial fix is setting aria-hidden="true" on the background content when a modal opens — correct and necessary for screen reader users, since it removes that content from the accessibility tree. But aria-hidden alone doesn't consistently prevent Tab-key focus from landing on covered elements across all browser and assistive-technology combinations; it needs to be paired with something that actually makes those elements non-focusable, not just invisible to screen readers.
No explicit trap logic leaves the default tab order completely unmanaged
Without code that intercepts Tab and Shift+Tab while the modal is open and explicitly cycles focus among only the modal's own focusable elements, the browser's default behavior — which spans the entire DOM, visible and covered alike — is left to run unmodified. The modal opening changes nothing about how Tab navigation actually behaves unless something is written specifically to change it.
Trap logic that exists can still have edge-case bugs
Hand-rolled focus-trap code that hardcodes which elements count as "first" and "last" focusable, or that doesn't re-query the modal's focusable elements after its content changes dynamically, can work for one modal instance and silently break for another — the trap appears correct in initial testing but fails for content shapes that weren't specifically tested.
The Fix
1. Use the native <dialog> element with showModal() where practical
<dialog id="confirmDialog">
<p>Are you sure?</p>
<button onclick="this.closest('dialog').close()">Cancel</button>
</dialog>
<script>
document.getElementById("confirmDialog").showModal();
</script>
The native <dialog> element, opened via showModal(), handles focus trapping and background inertness correctly by browser default — Tab navigation is automatically confined to the dialog's contents, and background content is automatically excluded from both the tab order and the accessibility tree, without any custom trap logic needed at all.
2. Where a custom modal is required, apply inert to everything outside it
document.getElementById("app-root").inert = true;
// ...open modal...
document.getElementById("app-root").inert = false;
// on close
The inert attribute reliably removes an entire subtree from both the accessibility tree and the tab order in one step — this is a stronger, more complete guarantee than aria-hidden alone, and it's specifically designed to solve exactly this problem rather than being a workaround repurposed for it.
3. Use a well-tested focus-trap library instead of hand-rolled interception logic
import { FocusTrap } from "focus-trap-react";
<FocusTrap active={isModalOpen}>
<div className="modal">{/* modal content */}</div>
</FocusTrap>
A library like focus-trap-react (or the underlying vanilla focus-trap) has already handled the edge cases that hand-rolled Tab-interception code tends to get subtly wrong — dynamically changing content, nested focusable elements, an empty or single-element modal — rather than requiring each of those to be independently discovered and fixed.
4. Test every modal with keyboard-only navigation specifically
Verify Tab, Shift+Tab, and Escape behavior on every modal in the app using only the keyboard — this entire class of bug is invisible to anyone testing with a mouse, since the visual result is identical whether or not the trap actually works. A modal that passes a visual review can still fail this check entirely.
Why This Works
Each fix addresses the actual gap: a visual overlay changes nothing about the DOM's tab order on its own. The native <dialog> element and the inert attribute both work at the level where the problem actually lives — removing background elements from the tab order itself, not just visually hiding them — while a well-tested focus-trap library handles the edge cases a hand-rolled implementation tends to miss. Testing with the keyboard specifically catches what a purely visual or mouse-driven review structurally cannot.
Conclusion
A modal that looks correct to a mouse user but lets keyboard focus escape to the page behind it is a DOM tab-order problem, not a visual one — z-index and overlays control what's seen, not what's reachable by Tab. Use the native <dialog> element with showModal() where practical, apply inert to background content for custom modal implementations, use a well-tested focus-trap library rather than hand-rolled interception logic, and test every modal specifically with keyboard-only navigation before considering the fix verified.