WSS
Web Specification Studio Home
On this page
BlogAccessibilityArchitectureHTMLCSSJavaScriptPublished

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:

  1. Forward Wrap (Tab): If document.activeElement === array[array.length - 1], the script calls event.preventDefault() and invokes array[0].focus().
  2. Backward Wrap (Shift + Tab): If document.activeElement === array[0], the script calls event.preventDefault() and invokes array[array.length - 1].focus().
  3. 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 like tabbable, walking the light DOM in strict pre-order depth-first tree order.

When these state machines disagree, two failures occur:

  1. Boundary Escape: document.activeElement === last evaluates to false on the final focus stop. The trap does not prevent default, and focus leaks into the background document.
  2. Silent Internal Lockout: document.activeElement === last evaluates to true prematurely. The script calls event.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:
    1. Native Browser Engine (Mode A): Unintercepted browser Tab navigation.
    2. Naive querySelectorAll Trap (Mode B): Standard selector query matching common interactive tags and attributes.
    3. tabbable Library (Mode C): The npm package (tabbable@6.2.0) configured with { getShadowRoot: true }.
    4. Native <dialog> and inert (Mode D): Platform modal isolation.

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() nor tabbable calculates CSS reading-flow, any flex container using reading-flow: flex-visual inverts 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:

  1. Native Browser Behavior: With flex-direction: row-reverse and reading-flow: flex-visual, Chromium renders #e1-dom-last on the far left. Unintercepted Tab keystrokes follow visual order: #e1-dom-last → #e1-dom-mid → #e1-dom-first → #after-1.
  2. Library Sorting Failure: Both querySelectorAll and tabbable returned DOM order: ["e1-dom-first", "e1-dom-mid", "e1-dom-last"]. They reported first = #e1-dom-first and last = #e1-dom-last.
  3. Premature Teleportation: When focus starts on the visual first element (#e1-dom-last), pressing Tab triggers the trap condition document.activeElement === last. The trap intercepts the key and teleports focus to #e1-dom-first, making #e1-dom-mid unreachable.
  4. Immediate Boundary Escape: Once focus sits on #e1-dom-first, the trap treats it as the first element. The next Tab press 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 a tabindex attribute, both querySelectorAll and tabbable omit 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:

  1. Native Browser Behavior: When pressing Tab on #e2-btn, Chromium moves focus directly to #e2-scroller. The scroller has no tabindex, yet it is part of the sequential cycle.
  2. Detection Failure: Both the naive selector and tabbable reported only one element: ["e2-btn"]. They set both first and last to #e2-btn.
  3. Silent Lockout: When the user tabs from #e2-btn toward the scroller, the trap evaluates document.activeElement === last as true. It intercepts the key and refocuses #e2-btn, preventing users from ever reaching #e2-scroller.
  4. Boundary Escape: If the user clicks into #e2-scroller and tabs forward, the trap evaluates activeElement === last as false. 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 tabindex values are evaluated across the entire focus navigation scope. For light-DOM divs, that scope is the root Document.

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:

  1. Document-Level Escape: When focus starts on #e3-priority (tabindex="1") and the user tabs, the browser moves focus to #before-1 at the top of the webpage.
  2. Scope Ownership: A plain <div> is not a focus navigation scope owner. The scope owner is the Document. When the user leaves the highest positive tabindex in the document, the engine transitions to the first tabindex="0" element in the document scope.
  3. Trap Ineffectiveness: Neither the naive trap nor tabbable intercepted Tab on #e3-priority, because both sorted it as the first element in the modal. Tabbing from #e3-priority escapes 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:

  1. Boundary Blocking: Under W3C DOM Standard § 4.2.6, querySelectorAll() cannot traverse across ShadowRoot boundaries. The selector only discovers ["e4-input"].
  2. Infinite Lockout: Because first and last both point to #e4-input, pressing Tab triggers document.activeElement === last immediately. The trap intercepts the key and refocuses #e4-input, preventing users from ever reaching the custom element.
  3. Retargeting Mismatch: When tabbable resolves #e4-shadow-btn using { getShadowRoot: true }, comparing document.activeElement === last still fails. W3C retargeting rules mean document.activeElement returns #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:

  1. Semantic Exclusion: In nativeSequence, only #e5-first and #e5-summary receive focus. The disabled fieldset deactivates #e5-disabled-input, the closed details element suppresses #e5-details-btn, and hidden visibility excludes #e5-vis-hidden.
  2. False Boundaries: querySelectorAll matched all five elements, setting last = #e5-vis-hidden. When the user reached #e5-summary and pressed Tab, the condition document.activeElement === #e5-vis-hidden failed. 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:

  1. 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.
  2. Escape via Missed Match: querySelectorAll designated #e6-r3 as last. When the user tabbed from #e6-r1, the comparison #e6-r1 === #e6-r3 returned false. 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:

  1. Starting Point Retention: Disabling #e7-submit preserves the Sequential Focus Navigation Starting Point at its DOM position.
  2. Candidate List Shrinkage: The trap re-queried for enabled elements. The array shrank to ["e7-input"], setting last = #e7-input.
  3. Mismatched Active Element: The trap evaluated document.activeElement === last (#e7-submit === #e7-input) as false. 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:

  1. 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.
  2. Scroller Inclusion: The engine automatically visited #e8-scroller in sequence (#e8-btn1 → #e8-scroller → #e8-btn2), briefly passed through body, and wrapped back to #e8-btn1.

4. Consolidated Experimental & Performance Telemetry Matrix

Empirical test matrix and micro-benchmarks captured live across 8 isolated fixtures in Comet Chromium 155
Figure 1: Full-page telemetry dashboard and micro-benchmark table captured live from the test runner in Comet 155.0.8059.40.

4.1 Functional Correctness Across All 8 Fixtures (Comet / Chromium 155.0.8059.40)

ExperimentBrowser C++ Native Tab SequenceNaive querySelectorAll Traptabbable npm Library Boundary CheckNative <dialog>.showModal() / Sibling inert
Exp 1: CSS reading-flow: flex-visuale1-dom-last → e1-dom-mid → e1-dom-firstFAIL (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-scrollerFAIL (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> modale3-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-btnFAIL (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:hiddene5-first → e5-summaryFAIL (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-r1FAIL (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-7FAIL (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-btn1N/AN/APASS (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 StrategyMean Execution Time (50 Runs)Relative OverheadArchitectural Mechanism
Platform HTML inert = true / showModal()0.000 ms per TabBaseline (0x)Zero DOM queries on keydown; enforced in C++ layout/focus engine.
Modern Element.checkVisibility() Filter0.066 ms1.0xDirect C++ box & CSS visibility check (checkVisibilityCSS: true).
Naive querySelectorAll(...)0.096 ms1.45xFast selector match, but functionally broken on Exps 1–7.
tabbable(container) Full Audit0.488 ms7.39x slowerInvokes synchronous window.getComputedStyle() and ancestor tree walks across every candidate on every Tab press.

[!NOTE] Performance Takeaway: Calling getComputedStyle() inside a keydown handler after DOM mutations forces synchronous style recalculation and layout flushes. On pages with 15,000+ DOM nodes, getComputedStyle walks can block the main thread for 8 to 15 ms per keystroke. Native Element.prototype.checkVisibility() runs 7.39x faster, and platform inert carries 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-visual and grid-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:

  1. Combine JS Loops with isolateModalWithInert(modal): Never rely on a keydown listener alone. If an unknown layout mode bypasses your trap, inert still protects background controls.
  2. Unwrap Active Elements Through Shadow Roots: Compare getDeepActiveElement() === last instead of document.activeElement === last.
  3. Detect Keyboard-Focusable Scroll Containers: Check whether non-interactive elements have overflow-x or overflow-y set to auto or scroll with overflowing content.
  4. Use Element.prototype.checkVisibility(): Replace slow getComputedStyle walks with el.checkVisibility({ checkVisibilityCSS: true, contentVisibilityAuto: true }) and semantic :not(:disabled).
  5. Handle Disabled Active Elements: If activeElement disables itself, use compareDocumentPosition() 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:

  1. 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-flow Support: Chromium supports reading-flow: flex-visual natively. 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 sequential Tab navigation unless “Use keyboard navigation to move focus between controls” is enabled in system settings.
  2. 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 all reading-flow modes without implementing full layout geometry. Platform-level inert remains the only containment pattern that guarantees complete isolation.

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 .showModal() better than JavaScript keydown interception?

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

  1. W3C CSS Display Module Level 4, Section 3.1 (The reading-flow property):
  2. WHATWG HTML Living Standard, Section 6.6 (Focus):
  3. WHATWG HTML Living Standard, Section 6.3 (Inert subtrees) & Section 4.11.4 (The <dialog> element):
  4. WHATWG HTML Living Standard, Section 4.10.5.1.12 (Radio Button state) & Section 4.10.18.5 (Disabling form controls):
  5. W3C DOM Living Standard, Section 4.2.6 (Mixin ParentNode) & Section 4.8 (Retargeting):
  6. W3C CSSOM View Module, Section 7.1 (Element.prototype.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.

Related posts