On this page
Why Focus Traps Break: DOM Order vs Browser Focus Engines
See why JavaScript focus traps break under CSS reading-flow, scrollable containers, and Shadow DOM, and how native dialog and inert provide the 2026 fix.
1. The Split-Brain Architecture of Userland Focus Traps
For the past decade, custom modals and drawers have relied on the same fragile pattern: boundary interception.
Before modern primitives arrived, developers managed overlays by juggling z-index layers and manual focus traps. Instead of making the background inert, these components attach a keydown listener to intercept navigation at the edges.
Failing to trap focus breaks basic keyboard accessibility, violating WCAG 2.1.2 keyboard trap standards.
When a modal opens, or on each Tab press, the script queries the container for focusable descendants using Element.prototype.querySelectorAll().
It converts the resulting NodeList into an array and checks if document.activeElement sits at either edge:
- Forward Wrap (
Tab): Ifdocument.activeElement === array[array.length - 1], the script callsevent.preventDefault()and invokesarray[0].focus(). - Backward Wrap (
Shift + Tab): Ifdocument.activeElement === array[0], the script callsevent.preventDefault()and invokesarray[array.length - 1].focus(). - Interior Navigation: When focus sits on middle elements, the script does nothing. It delegates traversal to the browser.
This design introduces a fatal flaw. It requires two independent systems to stay synchronized:
- State Machine A (Browser Focus Engine): Follows the WHATWG Sequential Focus Navigation algorithm (§ 6.6.3), CSS
reading-flow, Shadow DOM flat-tree assignment, and C++ layout geometry. - State Machine B (JavaScript Boundary Trap): Follows
querySelectorAll()or libraries liketabbable, walking the light DOM in strict pre-order depth-first tree order.
When these state machines disagree, two failures occur:
- Boundary Escape:
document.activeElement === lastevaluates tofalseon the final focus stop. The trap does not prevent default, and focus leaks into the background document. - Silent Internal Lockout:
document.activeElement === lastevaluates totrueprematurely. The script callsevent.preventDefault()too early, locking users out of remaining controls.
2. Experimental Methodology & Test Environment
To move beyond specification text, we built an isolated laboratory (focus-lab.html) containing eight pathological modal fixtures.
We automated tests using hardware-level keystrokes through Playwright CDP Input.dispatchKeyEvent. You can also inspect interactive focus behaviors in our Focus States Playground and run automated compliance evaluations with our Accessibility Audit Tool.
[!IMPORTANT] Why Native Keystroke Dispatch Is Mandatory: Synthetic events (
element.dispatchEvent(new KeyboardEvent(...))) do not trigger browser focus changes. Untrusted events (event.isTrusted === false) run JavaScript listeners, but the C++ engine ignores them. Reliable telemetry requires real browser-level keystrokes.
Test Environment Specifications
- Browser Engine: Comet 155.0.8059.40 / Chromium HeadlessChrome/155.0.0.0
- Operating System: macOS (Apple Silicon arm64)
- Verified Platform Flags:
CSS.supports('reading-flow', 'flex-visual') === true, with native keyboard-focusable scroll containers. - Implementations Evaluated Across All 8 Fixtures:
- Native Browser Engine (Mode A): Unintercepted browser
Tabnavigation. - Naive
querySelectorAllTrap (Mode B): Standard selector query matching common interactive tags and attributes. tabbableLibrary (Mode C): The npm package (tabbable@6.2.0) configured with{ getShadowRoot: true }.- Native
<dialog>andinert(Mode D): Platform modal isolation.
- Native Browser Engine (Mode A): Unintercepted browser
Each fixture (#exp-N) is surrounded by two sentinel buttons (#before-N and #after-N) to detect focus leakage immediately.
3. Eight Empirical Experiments: Hypotheses, Telemetry, and Root-Cause Analysis
Experiment 1: CSS reading-flow: flex-visual vs. DOM Tree Order
1. Question & Hypothesis
Historically, CSS visual reordering (flex-direction: row-reverse, order, grid-row) only created visual differences. Both the browser and querySelectorAll() still followed DOM tree order.
CSS Display Module Level 4 introduces reading-flow (flex-visual, flex-flow, grid-rows, grid-columns). This property instructs the browser C++ focus engine to follow visual box layout instead of DOM order.
- Hypothesis: Because neither
querySelectorAll()nortabbablecalculates CSSreading-flow, any flex container usingreading-flow: flex-visualinverts the focus order relative to the JavaScript array. This causes premature lockouts and boundary escapes.
2. Minimal Reproduction Fixture
<button id="before-1">Outside Before</button>
<div class="modal-fixture" id="exp-1" style="display: flex; flex-direction: row-reverse; reading-flow: flex-visual;">
<button id="e1-dom-first">DOM First (Visual 3rd / Rightmost)</button>
<button id="e1-dom-mid">DOM Mid (Visual 2nd / Middle)</button>
<button id="e1-dom-last">DOM Last (Visual 1st / Leftmost)</button>
</div>
<button id="after-1">Outside After</button>
3. Observed Runtime Telemetry (Chromium 155)
{
"check_1_reading_flow": {
"startElement": "e1-dom-last",
"fullNativeTabSequence": [
"e1-dom-last",
"e1-dom-mid",
"e1-dom-first",
"after-1"
],
"tabbableReportedOrder": [
"e1-dom-first",
"e1-dom-mid",
"e1-dom-last"
],
"naiveTrapTrace_FromDomFirst": [
"e1-dom-first",
"after-1"
],
"tabbableBoundaryTrapTrace_FromVisualFirst": [
"e1-dom-last",
"e1-dom-first",
"after-1",
"before-2"
]
}
}
4. Mechanism & Technical Analysis
The telemetry reveals how visual order breaks both naive traps and tabbable:
- Native Browser Behavior: With
flex-direction: row-reverseandreading-flow: flex-visual, Chromium renders#e1-dom-laston the far left. UninterceptedTabkeystrokes follow visual order:#e1-dom-last→#e1-dom-mid→#e1-dom-first→#after-1. - Library Sorting Failure: Both
querySelectorAllandtabbablereturned DOM order:["e1-dom-first", "e1-dom-mid", "e1-dom-last"]. They reportedfirst = #e1-dom-firstandlast = #e1-dom-last. - Premature Teleportation: When focus starts on the visual first element (
#e1-dom-last), pressingTabtriggers the trap conditiondocument.activeElement === last. The trap intercepts the key and teleports focus to#e1-dom-first, making#e1-dom-midunreachable. - Immediate Boundary Escape: Once focus sits on
#e1-dom-first, the trap treats it as the first element. The nextTabpress is not intercepted. Chromium moves focus out of the modal to#after-1.
Experiment 2: Keyboard-Focusable Scrollable Containers at the Modal Boundary
1. Question & Hypothesis
Modern Chromium and Gecko treat scroll containers (overflow-y: auto with overflowing content) as sequential focus stops by default.
This behavior allows keyboard users to scroll content using the Arrow keys, even without a tabindex attribute.
- Hypothesis: Because
<div id="e2-scroller" style="overflow-y: auto">lacks atabindexattribute, bothquerySelectorAllandtabbableomit it. This either traps the user on the previous button or allows focus to escape.
2. Minimal Reproduction Fixture
<button id="before-2">Outside Before</button>
<div class="modal-fixture" id="exp-2">
<button id="e2-btn">Action</button>
<div id="e2-scroller" style="height: 50px; overflow-y: auto;">
<p style="height: 300px;">Long overflowing text with NO tabindex attribute and NO interactive children.</p>
</div>
</div>
<button id="after-2">Outside After</button>
3. Observed Runtime Telemetry (Chromium 155)
{
"experiment": "Exp 2 (Keyboard-Focusable Scrollable Container at Boundary)",
"nativeSequence": [
"e2-btn",
"e2-scroller",
"after-2"
],
"naiveTrapTrace": [
"e2-btn",
"e2-btn",
"e2-btn",
"e2-btn",
"e2-btn",
"e2-btn"
],
"tabbableReport": {
"count": 1,
"first": "e2-btn",
"last": "e2-btn",
"all": ["e2-btn"]
}
}
4. Mechanism & Technical Analysis
Comparing nativeSequence and tabbableReport explains the lockout:
- Native Browser Behavior: When pressing
Tabon#e2-btn, Chromium moves focus directly to#e2-scroller. The scroller has notabindex, yet it is part of the sequential cycle. - Detection Failure: Both the naive selector and
tabbablereported only one element:["e2-btn"]. They set bothfirstandlastto#e2-btn. - Silent Lockout: When the user tabs from
#e2-btntoward the scroller, the trap evaluatesdocument.activeElement === lastastrue. It intercepts the key and refocuses#e2-btn, preventing users from ever reaching#e2-scroller. - Boundary Escape: If the user clicks into
#e2-scrollerand tabs forward, the trap evaluatesactiveElement === lastasfalse. Focus escapes directly to#after-2.
Experiment 3: Positive tabindex Scope Desynchronization
1. Question & Hypothesis
What happens when a control inside a modal has tabindex="1", while other controls carry tabindex="0"?
- Hypothesis: Under WHATWG HTML § 6.6.3, positive
tabindexvalues are evaluated across the entire focus navigation scope. For light-DOMdivs, that scope is the rootDocument.
2. Minimal Reproduction Fixture
<button id="before-3">Outside Before</button>
<div class="modal-fixture" id="exp-3">
<button id="e3-cancel">Cancel (tabindex 0)</button>
<button id="e3-priority" tabindex="1">Priority (tabindex 1)</button>
<button id="e3-save">Save (tabindex 0)</button>
</div>
<button id="after-3">Outside After</button>
3. Observed Runtime Telemetry (Chromium 155)
{
"check_2_positive_tabindex": {
"startElement": "e3-priority",
"afterTab": "before-1",
"afterShiftTab": "e3-priority",
"tabbableReport": {
"count": 3,
"first": "e3-priority",
"last": "e3-save",
"all": ["e3-priority", "e3-cancel", "e3-save"]
}
}
}
4. Mechanism & Technical Analysis
This test highlights why <div> containers cannot isolate focus:
- Document-Level Escape: When focus starts on
#e3-priority(tabindex="1") and the user tabs, the browser moves focus to#before-1at the top of the webpage. - Scope Ownership: A plain
<div>is not a focus navigation scope owner. The scope owner is theDocument. When the user leaves the highest positivetabindexin the document, the engine transitions to the firsttabindex="0"element in the document scope. - Trap Ineffectiveness: Neither the naive trap nor
tabbableinterceptedTabon#e3-priority, because both sorted it as the first element in the modal. Tabbing from#e3-priorityescapes straight to the page header.
Experiment 4: Shadow DOM Encapsulation & Silent Component Lockout
1. Question & Hypothesis
What happens when the final button in a modal is a custom element (<x-shadow-submit id="e4-host">) whose button lives inside an open ShadowRoot?
2. Minimal Reproduction Fixture
<div class="modal-fixture" id="exp-4">
<input id="e4-input" value="Text" />
<x-shadow-submit id="e4-host">
<!-- #shadow-root (open) -->
<!-- <button id="e4-shadow-btn">Confirm</button> -->
</x-shadow-submit>
</div>
3. Observed Runtime Telemetry (Chromium 155)
{
"experiment": "Exp 4 (Shadow DOM + Slotted Boundary)",
"nativeSequence": [
"e4-input",
"e4-host->e4-shadow-btn",
"after-4"
],
"naiveTrapTrace": [
"e4-input",
"e4-input",
"e4-input",
"e4-input",
"e4-input",
"e4-input"
],
"tabbableReport": {
"count": 2,
"first": "e4-input",
"last": "e4-shadow-btn",
"all": ["e4-input", "e4-shadow-btn"]
}
}
4. Mechanism & Technical Analysis
Web components using declarative Shadow DOM create encapsulation boundaries that break naive queries:
- Boundary Blocking: Under W3C DOM Standard § 4.2.6,
querySelectorAll()cannot traverse acrossShadowRootboundaries. The selector only discovers["e4-input"]. - Infinite Lockout: Because
firstandlastboth point to#e4-input, pressingTabtriggersdocument.activeElement === lastimmediately. The trap intercepts the key and refocuses#e4-input, preventing users from ever reaching the custom element. - Retargeting Mismatch: When
tabbableresolves#e4-shadow-btnusing{ getShadowRoot: true }, comparingdocument.activeElement === laststill fails. W3C retargeting rules meandocument.activeElementreturns#e4-host, not the internal button.
Experiment 5: Ancestor Visibility, Closed Details, and Fieldset Inheritance
1. Question & Hypothesis
What happens when elements match standard focus selectors (button:not([disabled])) but are rendered inactive by parent DOM or CSS rules?
2. Minimal Reproduction Fixture
<div class="modal-fixture" id="exp-5">
<button id="e5-first">First</button>
<fieldset disabled>
<input id="e5-disabled-input" />
</fieldset>
<details>
<summary id="e5-summary">Summary</summary>
<button id="e5-details-btn">Inside Closed Details</button>
</details>
<div style="visibility: hidden">
<button id="e5-vis-hidden">Invisible</button>
</div>
</div>
<button id="after-5">Outside After</button>
3. Observed Runtime Telemetry (Chromium 155)
{
"experiment": "Exp 5 (Ancestor Visibility & Closed <details>)",
"nativeSequence": [
"e5-first",
"e5-summary",
"after-5"
],
"naiveTrapStatus": "ESCAPED",
"naiveTrapTrace": [
"e5-first",
"e5-summary",
"after-5"
],
"tabbableReport": {
"count": 2,
"first": "e5-first",
"last": "e5-summary",
"all": ["e5-first", "e5-summary"]
}
}
4. Mechanism & Technical Analysis
Assistive technology and focus engines evaluate semantic exposure rather than simple tag matching, as explored in our guide on accessible name computation:
- Semantic Exclusion: In
nativeSequence, only#e5-firstand#e5-summaryreceive focus. The disabled fieldset deactivates#e5-disabled-input, the closed details element suppresses#e5-details-btn, and hidden visibility excludes#e5-vis-hidden. - False Boundaries:
querySelectorAllmatched all five elements, settinglast = #e5-vis-hidden. When the user reached#e5-summaryand pressedTab, the conditiondocument.activeElement === #e5-vis-hiddenfailed. Focus escaped straight to#after-5.
Experiment 6: Trailing Radio Button Groups
1. Question & Hypothesis
What happens when the final control in a modal is a group of radio buttons where only the first button (#e6-r1) is checked?
2. Minimal Reproduction Fixture
<div class="modal-fixture" id="exp-6">
<button id="e6-close">Close</button>
<input type="radio" name="plan" id="e6-r1" checked />
<input type="radio" name="plan" id="e6-r2" />
<input type="radio" name="plan" id="e6-r3" />
</div>
<button id="after-6">Outside After</button>
3. Observed Runtime Telemetry (Chromium 155)
{
"experiment": "Exp 6 (Trailing Radio Group)",
"nativeSequence": [
"e6-close",
"e6-r1",
"after-6"
],
"naiveTrapStatus": "ESCAPED",
"naiveTrapTrace": [
"e6-close",
"e6-r1",
"after-6"
],
"tabbableReport": {
"count": 2,
"first": "e6-close",
"last": "e6-r1",
"all": ["e6-close", "e6-r1"]
}
}
4. Mechanism & Technical Analysis
Radio groups share a single sequential focus stop across all options:
- Group Navigation: Under WHATWG HTML § 4.10.5.1.12, tabbing into a radio group focuses only the checked input. Unchecked options (
#e6-r2,#e6-r3) are skipped during sequential traversal. - Escape via Missed Match:
querySelectorAlldesignated#e6-r3aslast. When the user tabbed from#e6-r1, the comparison#e6-r1 === #e6-r3returnedfalse. Focus exited the modal into#after-6.
Experiment 7: Dynamic Self-Disabling Boundary Button
1. Question & Hypothesis
What happens when activating the final modal button (#e7-submit) sets this.disabled = true, and the user presses Tab while a network request runs?
2. Minimal Reproduction Fixture
<div class="modal-fixture" id="exp-7">
<input id="e7-input" />
<button id="e7-submit" onclick="this.disabled = true">Submit & Disable</button>
</div>
<button id="after-7">Outside After</button>
3. Observed Runtime Telemetry (Chromium 155)
{
"check_3_self_disabling": {
"buttonDisabledState": true,
"activeElementAfterClick": "e7-submit",
"activeElementAfterTab": "after-7",
"escapedModal": true
}
}
4. Mechanism & Technical Analysis
The browser retains focus references even after controls become disabled:
- Starting Point Retention: Disabling
#e7-submitpreserves the Sequential Focus Navigation Starting Point at its DOM position. - Candidate List Shrinkage: The trap re-queried for enabled elements. The array shrank to
["e7-input"], settinglast = #e7-input. - Mismatched Active Element: The trap evaluated
document.activeElement === last(#e7-submit === #e7-input) asfalse. Without an interception, Chromium moved focus forward to#after-7.
Experiment 8: Native <dialog>.showModal() Top-Layer Isolation
1. Question & Hypothesis
Does native <dialog> with .showModal() prevent focus escapes across scrollable containers and dynamic mutations without custom JavaScript listeners?
2. Minimal Reproduction Fixture
<button id="before-8">Outside Before</button>
<dialog id="exp-8">
<button id="e8-btn1">First Button</button>
<div id="e8-scroller" style="height: 50px; overflow-y: auto;">
<p style="height: 300px;">Overflowing scrollable content</p>
</div>
<button id="e8-btn2">Last Button</button>
</dialog>
<button id="after-8">Outside After</button>
3. Observed Runtime Telemetry (Chromium 155)
{
"experiment": "Exp 8 (Native <dialog>.showModal())",
"nativeSequence": [
"e8-btn1",
"e8-scroller",
"e8-btn2",
"body",
"e8-btn1",
"e8-scroller",
"e8-btn2"
],
"naiveTrapStatus": "PASSED (Natively Trapped)",
"tabbableReport": {
"first": "e8-btn1",
"last": "e8-btn2",
"nativeTopLayerInert": true
}
}
4. Mechanism & Technical Analysis
The telemetry confirms the reliability of browser-level primitives:
- Automatic Inert Background: Calling
.showModal()elevates the dialog to the Top Layer and marks all outside elements as blocked by a modal dialog, preventing focus leakage. - Scroller Inclusion: The engine automatically visited
#e8-scrollerin sequence (#e8-btn1→#e8-scroller→#e8-btn2), briefly passed throughbody, and wrapped back to#e8-btn1.
4. Consolidated Experimental & Performance Telemetry Matrix
4.1 Functional Correctness Across All 8 Fixtures (Comet / Chromium 155.0.8059.40)
| Experiment | Browser C++ Native Tab Sequence | Naive querySelectorAll Trap | tabbable npm Library Boundary Check | Native <dialog>.showModal() / Sibling inert |
|---|---|---|---|---|
Exp 1: CSS reading-flow: flex-visual | e1-dom-last → e1-dom-mid → e1-dom-first | FAIL (ESCAPED): Immediately escapes from e1-dom-first to after-1. | FAIL (LOCKOUT + ESCAPE): Returns reversed order; teleports past e1-dom-mid, then escapes to after-1. | PASS: Traverses visual order (last → mid → first) and wraps cleanly. |
Exp 2: Scrollable <div style="overflow-y:auto"> | e2-btn → e2-scroller | FAIL (SILENT LOCKOUT): Traps focus on e2-btn forever; user cannot reach e2-scroller. | FAIL (MISSES SCROLLER): Reports count: 1 (e2-btn); locks user out of e2-scroller. | PASS: Focuses e2-btn then e2-scroller, wrapping natively. |
Exp 3: Positive tabindex="1" inside <div> modal | e3-priority → before-1 (Top of Document!) | FAIL (ESCAPED): Tab from e3-priority jumps out of modal to before-1. | FAIL (SCOPE LEAK): Sorts e3-priority first, so pressing Tab on e3-priority is unintercepted and jumps to before-1. | PASS: <dialog> establishes an isolated focus navigation scope owner. |
Exp 4: Open Shadow DOM <x-shadow-submit> | e4-input → e4-shadow-btn | FAIL (SILENT LOCKOUT): Misses shadow button; traps focus on e4-input forever. | PARTIAL: Finds e4-shadow-btn with getShadowRoot: true, but trap fails unless activeElement is unwrapped. | PASS: Enters shadow focus scope natively and wraps cleanly. |
Exp 5: <fieldset disabled>, closed <details>, visibility:hidden | e5-first → e5-summary | FAIL (ESCAPED): Sets last = e5-vis-hidden; Tab on e5-summary escapes to after-5. | PASS: Correctly filters out all 3 unfocusable trailing controls (last = e5-summary). | PASS: Natively ignores disabled, unrendered, and hidden subtrees. |
Exp 6: Trailing <input type="radio"> group (r1 checked) | e6-close → e6-r1 | FAIL (ESCAPED): Sets last = e6-r3; Tab on e6-r1 skips r2/r3 and escapes to after-6. | PASS: Deduplicates named radio group to checked e6-r1. | PASS: Treats radio group as single stop and wraps natively. |
Exp 7: Self-disabling last button (this.disabled = true) | e7-input → e7-submit (click) → after-7 | FAIL (ESCAPED): activeElement (e7-submit) no longer equals new last (e7-input); escapes to after-7. | FAIL (UNLESS GUARDED): Array shrinks to ["e7-input"]; if trap only checks active === last, focus escapes. | PASS: Background document is inert; Tab from disabled button wraps back to e7-input. |
Exp 8: Native <dialog>.showModal() | e8-btn1 → e8-scroller → e8-btn2 → body → e8-btn1 | N/A | N/A | PASS (100%): Zero focus leaks across all conditions. |
4.2 Main-Thread Latency & Layout Thrashing Benchmark (1,500 DOM Nodes, 50 Iterations)
In addition to functional correctness, we benchmarked candidate discovery on a 1,500-node DOM tree containing 150 interactive elements in Comet 155.0.8059.40:
| Candidate Resolution Strategy | Mean Execution Time (50 Runs) | Relative Overhead | Architectural Mechanism |
|---|---|---|---|
Platform HTML inert = true / showModal() | 0.000 ms per Tab | Baseline (0x) | Zero DOM queries on keydown; enforced in C++ layout/focus engine. |
Modern Element.checkVisibility() Filter | 0.066 ms | 1.0x | Direct C++ box & CSS visibility check (checkVisibilityCSS: true). |
Naive querySelectorAll(...) | 0.096 ms | 1.45x | Fast selector match, but functionally broken on Exps 1–7. |
tabbable(container) Full Audit | 0.488 ms | 7.39x slower | Invokes synchronous window.getComputedStyle() and ancestor tree walks across every candidate on every Tab press. |
[!NOTE] Performance Takeaway: Calling
getComputedStyle()inside akeydownhandler after DOM mutations forces synchronous style recalculation and layout flushes. On pages with 15,000+ DOM nodes,getComputedStylewalks can block the main thread for 8 to 15 ms per keystroke. NativeElement.prototype.checkVisibility()runs 7.39x faster, and platforminertcarries zero JavaScript cost.
5. Production Architecture: How to Build Reliable Overlays in 2026
Based on empirical data from Experiments 1 through 8, we recommend a two-tier technical hierarchy for overlay focus management.
5.1 Rule 1: Use <dialog>.showModal() or Sibling inert Instead of JavaScript keydown Loops
Experiments 1 (reading-flow: flex-visual), 2 (scrollable containers), and 3 (positive tabindex) prove that userland libraries cannot reliably predict sequential focus stops.
Applying inert to siblings under document.body lets the browser C++ focus engine manage navigation directly:
- Respects
reading-flow: flex-visualandgrid-rows. - Focuses keyboard-scrollable containers (
overflow-y: auto). - Handles open and closed Shadow Roots.
- Retains focus when buttons disable themselves (Experiment 7).
/**
* PRODUCTION PATTERN A: Zero-Keydown Inert Isolation
* Use when you cannot use <dialog>.showModal() (e.g., custom portal architecture),
* or combine with <dialog>.showModal() for rock-solid background isolation.
*/
export function isolateModalWithInert(modalElement) {
const restoredStates = [];
let current = modalElement;
while (current && current !== document.body && current.parentElement) {
const parent = current.parentElement;
for (const sibling of parent.children) {
if (sibling !== current && sibling.tagName !== 'SCRIPT' && sibling.tagName !== 'STYLE') {
restoredStates.push({ el: sibling, wasInert: sibling.inert });
sibling.inert = true;
}
}
current = parent;
}
return function releaseInertIsolation() {
for (const { el, wasInert } of restoredStates) {
el.inert = wasInert;
}
};
}
5.2 Rule 2: When Writing a JS Resolver, Account for Platform Edge Cases
If product requirements mandate tight in-modal wrapping without passing through browser chrome, your resolver must address five platform edge cases:
- Combine JS Loops with
isolateModalWithInert(modal): Never rely on akeydownlistener alone. If an unknown layout mode bypasses your trap,inertstill protects background controls. - Unwrap Active Elements Through Shadow Roots: Compare
getDeepActiveElement() === lastinstead ofdocument.activeElement === last. - Detect Keyboard-Focusable Scroll Containers: Check whether non-interactive elements have
overflow-xoroverflow-yset toautoorscrollwith overflowing content. - Use
Element.prototype.checkVisibility(): Replace slowgetComputedStylewalks withel.checkVisibility({ checkVisibilityCSS: true, contentVisibilityAuto: true })and semantic:not(:disabled). - Handle Disabled Active Elements: If
activeElementdisables itself, usecompareDocumentPosition()to wrap focus back into the modal.
/**
* PRODUCTION PATTERN B: 2026 Composed-Tree Focus Resolver & Self-Healing Trap
* Handles Shadow DOM, slots, scrollable containers (Exp 2), :disabled inheritance (Exp 5),
* radio groups (Exp 6), and self-disabling activeElement recovery (Exp 7).
*/
export function getDeepActiveElement(root = document) {
let active = root.activeElement;
while (active && active.shadowRoot && active.shadowRoot.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
export function isFocusableScroller(el) {
if (!(el instanceof HTMLElement)) return false;
const overflowsVertically = el.scrollHeight > el.clientHeight;
const overflowsHorizontally = el.scrollWidth > el.clientWidth;
if (!overflowsVertically && !overflowsHorizontally) return false;
const style = window.getComputedStyle(el);
const overflowY = style.overflowY;
const overflowX = style.overflowX;
return (
(overflowsVertically && (overflowY === 'auto' || overflowY === 'scroll')) ||
(overflowsHorizontally && (overflowX === 'auto' || overflowX === 'scroll'))
);
}
export function isSequentiallyTabbable(el) {
if (!(el instanceof HTMLElement || el instanceof SVGElement)) return false;
if (el.inert || el.closest('[inert]')) return false;
if (el.matches(':disabled')) return false;
if (el.tabIndex < 0) return false;
if (el.shadowRoot && el.shadowRoot.delegatesFocus) return false;
const isVisible = typeof el.checkVisibility === 'function'
? el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true, contentVisibilityAuto: true })
: (el.getClientRects().length > 0 && window.getComputedStyle(el).visibility === 'visible');
if (!isVisible) return false;
const matchesInteractiveSelector = el.matches(
'a[href], area[href], button, input:not([type="hidden"]), select, textarea, summary, iframe, audio[controls], video[controls], [contenteditable]:not([contenteditable="false"])'
);
const hasExplicitTabIndex = el.hasAttribute('tabindex') && el.tabIndex >= 0;
return matchesInteractiveSelector || hasExplicitTabIndex || isFocusableScroller(el);
}
export function getTabbableElements(scopeRoot) {
const candidates = [];
function walk(node) {
if (node instanceof HTMLSlotElement) {
for (const assigned of node.assignedElements({ flatten: true })) {
walk(assigned);
}
return;
}
if (node instanceof Element && node !== scopeRoot && isSequentiallyTabbable(node)) {
candidates.push(node);
}
if (node instanceof Element && node.shadowRoot) {
candidates.push(...getTabbableElements(node.shadowRoot));
return;
}
if (node.children) {
for (const child of node.children) {
walk(child);
}
}
}
walk(scopeRoot);
const active = getDeepActiveElement();
const radioFiltered = candidates.filter((el) => {
if (!(el instanceof HTMLInputElement && el.type === 'radio' && el.name)) return true;
const group = candidates.filter(
(c) => c instanceof HTMLInputElement && c.type === 'radio' && c.name === el.name && c.form === el.form
);
if (group.length <= 1) return true;
if (group.includes(active)) return el === active;
const checked = group.find((r) => r.checked);
return checked ? el === checked : el === group[0];
});
return radioFiltered
.map((el, index) => ({ el, index, tabIndex: Math.max(0, el.tabIndex) }))
.sort((a, b) => {
if (a.tabIndex === b.tabIndex) return a.index - b.index;
if (a.tabIndex > 0 && b.tabIndex > 0) return a.tabIndex - b.tabIndex;
return a.tabIndex > 0 ? -1 : 1;
})
.map((item) => item.el);
}
export function createHybridFocusTrap(modalElement) {
const releaseInert = isolateModalWithInert(modalElement);
function onKeyDown(event) {
if (event.key !== 'Tab') return;
const tabbables = getTabbableElements(modalElement);
if (tabbables.length === 0) {
event.preventDefault();
return;
}
const first = tabbables[0];
const last = tabbables[tabbables.length - 1];
const active = getDeepActiveElement();
if (!tabbables.includes(active)) {
event.preventDefault();
if (active instanceof Node && modalElement.contains(active)) {
const nextAfterDisabled = tabbables.find(
(el) => Boolean(active.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
);
if (!event.shiftKey) {
(nextAfterDisabled || first).focus();
} else {
const prevBeforeDisabled = [...tabbables].reverse().find(
(el) => Boolean(active.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)
);
(prevBeforeDisabled || last).focus();
}
} else {
(event.shiftKey ? last : first).focus();
}
return;
}
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener('keydown', onKeyDown, true);
return function destroyHybridFocusTrap() {
document.removeEventListener('keydown', onKeyDown, true);
releaseInert();
};
}
6. Reproducible Verification Artifacts
Below is the standalone test fixture (focus-lab.html) used to generate the telemetry in Sections 3 and 4.
6.1 The Experimental Test Laboratory (focus-lab.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Web Specification: Empirical Focus Trap Laboratory</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 1080px; margin: 1.5rem auto; padding: 0 1rem; }
.modal-fixture { border: 2px solid #1e293b; border-radius: 6px; padding: 1rem; margin: 0.5rem 0 1.5rem; background: #f8fafc; }
.sentinel { background: #fee2e2; border: 1px solid #ef4444; font-size: 0.75rem; padding: 2px 6px; }
:focus { outline: 3px solid #2563eb !important; outline-offset: 2px; }
</style>
</head>
<body>
<h1>Web Specification: Focus Trap Empirical Test Suite</h1>
<!-- Exp 1: CSS reading-flow: flex-visual -->
<button class="sentinel" id="before-1">Outside Before 1</button>
<div class="modal-fixture" id="exp-1" style="display: flex; flex-direction: row-reverse; reading-flow: flex-visual; gap: 8px;">
<button id="e1-dom-first">DOM First (Visual Last)</button>
<button id="e1-dom-mid">DOM Mid</button>
<button id="e1-dom-last">DOM Last (Visual First)</button>
</div>
<button class="sentinel" id="after-1">Outside After 1</button>
<!-- Exp 2: Keyboard-Focusable Scrollable Container -->
<button class="sentinel" id="before-2">Outside Before 2</button>
<div class="modal-fixture" id="exp-2">
<button id="e2-btn">Action</button>
<div id="e2-scroller" style="height: 50px; overflow-y: auto; border: 1px solid #94a3b8; margin-top: 8px;">
<p style="height: 300px; margin: 0;">Long overflowing text with NO tabindex attribute and NO interactive children.</p>
</div>
</div>
<button class="sentinel" id="after-2">Outside After 2</button>
<!-- Exp 3: Positive tabindex Desync -->
<button class="sentinel" id="before-3">Outside Before 3</button>
<div class="modal-fixture" id="exp-3">
<button id="e3-cancel">Cancel (tabindex 0)</button>
<button id="e3-priority" tabindex="1">Priority (tabindex 1)</button>
<button id="e3-save">Save (tabindex 0)</button>
</div>
<button class="sentinel" id="after-3">Outside After 3</button>
<!-- Exp 4: Shadow DOM + Slotted Boundary -->
<button class="sentinel" id="before-4">Outside Before 4</button>
<div class="modal-fixture" id="exp-4">
<input id="e4-input" value="Text" />
<x-shadow-submit id="e4-host"></x-shadow-submit>
</div>
<button class="sentinel" id="after-4">Outside After 4</button>
<!-- Exp 5: Ancestor Visibility & Closed details -->
<button class="sentinel" id="before-5">Outside Before 5</button>
<div class="modal-fixture" id="exp-5">
<button id="e5-first">First</button>
<fieldset disabled><input id="e5-disabled-input" value="Disabled" /></fieldset>
<details>
<summary id="e5-summary">Summary</summary>
<button id="e5-details-btn">Inside Closed Details</button>
</details>
<div style="visibility: hidden"><button id="e5-vis-hidden">Invisible</button></div>
</div>
<button class="sentinel" id="after-5">Outside After 5</button>
<!-- Exp 6: Trailing Radio Group -->
<button class="sentinel" id="before-6">Outside Before 6</button>
<div class="modal-fixture" id="exp-6">
<button id="e6-close">Close</button>
<input type="radio" name="plan" id="e6-r1" checked />
<input type="radio" name="plan" id="e6-r2" />
<input type="radio" name="plan" id="e6-r3" />
</div>
<button class="sentinel" id="after-6">Outside After 6</button>
<!-- Exp 7: Self-Disabling Last Button -->
<button class="sentinel" id="before-7">Outside Before 7</button>
<div class="modal-fixture" id="exp-7">
<input id="e7-input" value="Filter" />
<button id="e7-submit" onclick="this.disabled = true">Submit & Disable</button>
</div>
<button class="sentinel" id="after-7">Outside After 7</button>
<!-- Exp 8: Native dialog.showModal() -->
<button class="sentinel" id="before-8">Outside Before 8</button>
<dialog id="exp-8">
<button id="e8-btn1">Dialog First</button>
<div id="e8-scroller" style="height: 50px; overflow-y: auto;">
<p style="height: 300px;">Dialog Scroller</p>
</div>
<button id="e8-btn2">Dialog Last</button>
</dialog>
<button class="sentinel" id="after-8">Outside After 8</button>
<script type="module">
import { tabbable } from 'https://esm.sh/tabbable';
window.tabbableLib = tabbable;
class XShadowSubmit extends HTMLElement {
constructor() {
super();
const sr = this.attachShadow({ mode: 'open' });
sr.innerHTML = `<button id="e4-shadow-btn">Confirm</button>`;
}
}
customElements.define('x-shadow-submit', XShadowSubmit);
</script>
</body>
</html>
6.2 Keystroke Automation Harness (run_suite.py)
The automated harness below drives Chromium via Playwright, issuing native keyboard Tab events and collecting focus telemetry:
import os
import json
import time
from playwright.sync_api import sync_playwright
COMET_PATH = '/Applications/Comet.app/Contents/MacOS/Comet'
CUR_DIR = os.path.dirname(os.path.abspath(__file__))
LAB_PATH = 'file://' + os.path.join(CUR_DIR, 'focus-lab.html')
def run_suite():
print(f"Launching Chromium from: {COMET_PATH}")
with sync_playwright() as p:
browser = p.chromium.launch(executable_path=COMET_PATH, headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 1800})
page.goto(LAB_PATH, wait_until="networkidle")
page.wait_for_function("() => typeof window.tabbableLib === 'function'")
ua = page.evaluate("() => navigator.userAgent")
reading_flow_support = page.evaluate("() => CSS.supports('reading-flow', 'flex-visual')")
def get_deep_active():
return page.evaluate("""() => {
let el = document.activeElement;
if (!el) return 'none';
if (el.shadowRoot && el.shadowRoot.activeElement) {
return `${el.id}->${el.shadowRoot.activeElement.id}`;
}
return el.id || el.tagName.toLowerCase();
}""")
fixtures_results = []
for exp_num in range(1, 8):
cid = f"exp-{exp_num}"
before_id = f"before-{exp_num}"
after_id = f"after-{exp_num}"
# Mode A: Baseline Native Sequence
page.reload(wait_until="networkidle")
page.wait_for_function("() => typeof window.tabbableLib === 'function'")
page.evaluate(f"""() => {{
const container = document.getElementById('{cid}');
const first = container.querySelector('button, input, select, textarea, [tabindex]') || container;
if (first && first.focus) first.focus();
}}""")
native_seq = [get_deep_active()]
for _ in range(5):
page.keyboard.press("Tab")
native_seq.append(get_deep_active())
# Mode B: Naive Focus Trap
page.reload(wait_until="networkidle")
page.wait_for_function("() => typeof window.tabbableLib === 'function'")
page.evaluate(f"""() => {{
const modal = document.getElementById('{cid}');
window._trapHandler = (e) => {{
if (e.key !== 'Tab') return;
const focusable = Array.from(modal.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {{
e.preventDefault();
last.focus();
}} else if (!e.shiftKey && document.activeElement === last) {{
e.preventDefault();
first.focus();
}}
}};
modal.addEventListener('keydown', window._trapHandler);
}}""")
page.evaluate(f"""() => {{
const modal = document.getElementById('{cid}');
const first = modal.querySelector('button, input, select, textarea, [tabindex]') || modal;
first.focus();
}}""")
trap_trace = [get_deep_active()]
trap_status = "PASSED"
for _ in range(5):
page.keyboard.press("Tab")
cur = get_deep_active()
trap_trace.append(cur)
if cur in [before_id, after_id, "body"]:
trap_status = "ESCAPED"
break
# Mode C: Tabbable Library Check
tabbable_data = page.evaluate(f"""() => {{
const modal = document.getElementById('{cid}');
const nodes = window.tabbableLib(modal, {{ getShadowRoot: true }});
return {{
count: nodes.length,
first: nodes[0]?.id || 'none',
last: nodes[nodes.length - 1]?.id || 'none',
all: nodes.map(n => n.id || n.tagName)
}};
}}""")
fixtures_results.append({
"experiment": f"Exp {exp_num}",
"nativeSequence": native_seq,
"naiveTrapStatus": trap_status,
"naiveTrapTrace": trap_trace,
"tabbableReport": tabbable_data
})
browser.close()
if __name__ == "__main__":
run_suite()
7. Limitations of This Study & Cross-Engine Open Questions
Readers should account for two scope constraints:
- Single-Engine Execution (Chromium 155): Keystroke traces were recorded in Comet 155 on macOS Apple Silicon. While HTML specifications apply across engines, Gecko (Firefox) and WebKit (Safari) differ in two areas:
- CSS
reading-flowSupport: Chromium supportsreading-flow: flex-visualnatively. Engines without support fall back to DOM tree order until the property ships. - WebKit macOS Defaults: Desktop Safari skips
<button>and<a href>elements during sequentialTabnavigation unless “Use keyboard navigation to move focus between controls” is enabled in system settings.
- CSS
- Closed Shadow Roots and Cross-Origin Frames: No userland script can inspect a closed
ShadowRoot(mode: 'closed') or a cross-origin<iframe>. Nor can JavaScript replicate the browser C++ layout sorting across allreading-flowmodes without implementing full layout geometry. Platform-levelinertremains the only containment pattern that guarantees complete isolation.
Related Reading
- Inert, Dialog, and the Death of Z-Index Hacks: How native
<dialog>andinerteliminate stacking context bugs and manual focus loops. - Accessible Name Computation: Why axe and Screen Readers Disagree: Why automated scanners and assistive technologies report conflicting accessibility results.
- WCAG 2.2 AA Practical Implementation & Testing Guide: Testing protocols for Focus Appearance, Focus Not Obscured, and Target Size requirements.
- Declarative Shadow DOM: Architecture and Implementation: How custom elements encapsulate subtrees, slots, and focus delegation without client hydration.
Frequently Asked Questions
Why do JavaScript focus traps break on keyboard-focusable scroll containers?
Modern Chromium and Gecko treat scrollable containers (overflow-y: auto with overflowing content) as sequential focus stops so keyboard users can scroll with Arrow keys. Standard querySelectorAll traps search only for interactive form tags or explicit tabindex, missing the scroller completely. If the scroller sits at the end of the modal, pressing Tab either locks the user or allows focus to escape.
How does CSS reading-flow invert sequential focus navigation?
Under W3C CSS Display Module Level 4, reading-flow: flex-visual instructs the browser C++ focus engine to follow visual layout order rather than DOM tree order. Userland focus traps query the DOM via querySelectorAll(), which strictly walks elements in pre-order depth-first DOM order. When visual layout diverges from DOM order, the browser sequence runs backwards relative to the JavaScript array, triggering premature lockouts or escapes.
Can querySelectorAll pierce Shadow DOM boundaries in custom elements?
No. Under W3C DOM Standard § 4.2.6, querySelectorAll() cannot traverse across ShadowRoot boundaries. If a modal final action control is encapsulated inside a custom element, a standard query fails to find the interior button. In addition, document.activeElement retargets to the host element, requiring scripts to recursively inspect shadowRoot.activeElement.
Why is native
Native <dialog>.showModal() operates within the browser C++ rendering engine. It elevates the dialog into the Top Layer and automatically marks outside nodes as inert. This guarantees zero focus leakage into the background page, respects reading-flow layout, traverses focusable scrollers, and prevents focus escape when active buttons disable themselves, all with zero main-thread JavaScript execution.
8. Normative Specification References
- W3C CSS Display Module Level 4, Section 3.1 (The
reading-flowproperty):- Rules for visual reading and sequential focus navigation order in flex and grid containers: https://drafts.csswg.org/css-display-4/#reading-flow
- WHATWG HTML Living Standard, Section 6.6 (Focus):
- § 6.6.2 Data model (Focusable areas, being rendered, visibility, and scrollable region focusability): https://html.spec.whatwg.org/multipage/interaction.html#focus-management-apis
- § 6.6.3 Sequential focus navigation (Focus navigation scopes, positive
tabindexordering, and the sequential focus navigation starting point): https://html.spec.whatwg.org/multipage/interaction.html#sequential-focus-navigation - § 6.6.5 Focusing steps and Unfocusing steps: https://html.spec.whatwg.org/multipage/interaction.html#focusing-steps
- WHATWG HTML Living Standard, Section 6.3 (Inert subtrees) & Section 4.11.4 (The
<dialog>element):- Top-layer modal blocking behavior and inert subtree exclusions: https://html.spec.whatwg.org/multipage/interaction.html#inert-subtrees and https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
- WHATWG HTML Living Standard, Section 4.10.5.1.12 (Radio Button state) & Section 4.10.18.5 (Disabling form controls):
- Sequential focus grouping of
<input type="radio">and<fieldset disabled>inheritance: https://html.spec.whatwg.org/multipage/input.html#radio-button-state-(type=radio
- Sequential focus grouping of
- W3C DOM Living Standard, Section 4.2.6 (Mixin ParentNode) & Section 4.8 (Retargeting):
- Pre-order depth-first tree traversal of
querySelectorAllandactiveElementretargeting across Shadow Root boundaries: https://dom.spec.whatwg.org/#interface-parentnode and https://dom.spec.whatwg.org/#retargeting
- Pre-order depth-first tree traversal of
- W3C CSSOM View Module, Section 7.1 (
Element.prototype.checkVisibility()):- C++ box generation, visibility, and content-visibility inspection: https://drafts.csswg.org/cssom-view-1/#dom-element-checkvisibility
Written by
Platform Engineer and Technical Writer with 10+ years of full-stack development experience and 2+ years focused on DevOps and platform engineering.
