Detecting Focus Traps in Automated Audits
To detect a WCAG 2.1.2 keyboard trap automatically, press Tab in a loop and watch for the moment focus stops advancing — cycling among a fixed subset of elements with no key able to move out — then distinguish a genuine trap from an intentionally contained modal by pressing Escape and confirming focus is released back to the page. This page shows the loop-and-escape probe that separates the two, so a conforming dialog is not flagged and a real trap never slips through.
This is a focused technique within the Keyboard and Focus Order Auditing guide, under the Enterprise WCAG Audit Architecture & Standards Mapping section. It is distinct from the order check its sibling covers: order asks whether a completed walk was meaningful, whereas trap detection is concerned with walks that never complete at all.
When This Applies
Reach for a trap probe whenever a page contains a region that can capture focus and hold it. The high-risk surfaces are specific:
- Modal dialogs and drawers that implement focus containment — deliberately keeping Tab inside the dialog while it is open. This is required behavior, but only if
Escape(or a visible close control) releases focus. A containment with no escape route is the textbook 2.1.2 failure. - Embedded third-party widgets — a video player, a rich-text editor, a payment iframe — that grab focus and never hand it back to the host document.
- Custom keyboard handlers that call
preventDefault()onTabto build their own navigation but forget an exit, stranding the user inside a menu or grid. - Legacy plugin content where focus enters an object and the browser cannot Tab past it.
If focus advances normally and simply visits controls in an awkward sequence, that is a 2.4.3 order problem, not a trap — handle it with the sibling order check. Trap detection only fires when advancement stops.
Minimal Reproducible Example
The naive detector concludes “trap” the instant it sees focus stay on the same element for two presses. That produces false positives on every well-behaved modal, because a modal is supposed to contain focus — the naive check cannot tell containment-with-escape from a true trap.
# trap_naive.py — DO NOT SHIP. Flags every focus-containing modal as a trap.
seen = set()
for _ in range(100):
page.keyboard.press("Tab")
key = page.evaluate("document.activeElement.id")
if key in seen:
raise AssertionError("focus trap!") # fires on ANY loop, including
seen.add(key) # a correct modal that releases on EscapeTwo flaws. It treats any revisited element as a trap, so a modal that cycles its own three controls (correct behavior) trips it. And it never presses Escape, so it cannot observe the one thing that separates a conforming contained dialog from a real trap: whether focus can be released.
Correct Implementation
Detect non-advancement as a suspicion, then run the escape probe to reach a verdict. A region that contains focus but releases it on Escape conforms; one that contains focus with no working escape is the trap.
# trap_probe.py — suspect on non-advancement, confirm with an Escape probe.
from playwright.async_api import Page
ACTIVE_KEY = """
() => {
const el = document.activeElement;
if (!el || el === document.body) return "__document__";
return el.id || el.getAttribute('aria-label') || el.outerHTML.slice(0, 60);
}
"""
async def find_trap(page: Page, max_steps: int = 120) -> dict | None:
"""Return a trap verdict, or None if focus escapes normally."""
visited, ring = [], []
for _ in range(max_steps):
await page.keyboard.press("Tab")
key = await page.evaluate(ACTIVE_KEY)
if key == "__document__":
return None # focus left to browser chrome — free
ring.append(key)
# A trap: the last N presses only revisit a small fixed set of nodes.
window = ring[-12:]
if len(window) == 12 and len(set(window)) <= 4:
return await _escape_probe(page, set(window))
visited.append(key)
# Never advanced out and never cycled cleanly — treat as a trap.
return await _escape_probe(page, set(ring[-12:]))
async def _escape_probe(page: Page, contained: set[str]) -> dict | None:
"""Press Escape; if focus leaves the contained set, it was a conforming modal."""
await page.keyboard.press("Escape")
after = await page.evaluate(ACTIVE_KEY)
if after == "__document__" or after not in contained:
# Focus was released — this is intentional containment, not a trap.
return None
# Escape did nothing and Tab could not leave: a real keyboard trap.
return {"criterion": "2.1.2", "trapped_in": sorted(contained)}The two-part shape is the whole point. Non-advancement — twelve presses cycling four or fewer nodes — raises suspicion cheaply without a browser-specific hook. The Escape probe then resolves it: a dialog that yields focus back to the document (or to the control that opened it) is doing exactly what an accessible modal must, while a region that swallows Escape and keeps Tab captive is the failure. Widen the window (12) and the distinct-node ceiling (4) to match the largest legitimate in-modal control count on your routes so a big form-in-a-dialog is not mistaken for a trap.
Pipeline Integration Note
This probe runs as the final step of the keyboard job in the parent guide, after reachability and order, because a trap invalidates those earlier results — you cannot trust a focus path that never completed. When find_trap returns a verdict it should also capture a trace and a screenshot at the trapped state, since a 2.1.2 finding is hard to action from a bare element list; that evidence rides the same artifact upload the axe gate uses. A confirmed trap is always a blocking, critical-severity finding — unlike order anomalies it is never a warning — and routes straight into triage. Where the trapping region is a third-party embed rather than owned code, reconcile ownership against dynamic content boundary detection before assigning the ticket, and provision the traversal harness through the Playwright headless scanning workflows setup.
Gotchas
- A modal that traps only on Shift+Tab. Some focus managers guard forward Tab at the last element but leak backward Tab at the first, or vice versa. Run the probe in both directions; a region can be a trap in reverse while looking clean forward.
- Escape bound to close but not to restore focus. A dialog can close on
Escapeyet drop focus to<body>instead of returning it to the invoking control. That is not a 2.1.2 trap, but it is a focus-management defect worth a separate lower-severity finding — assert focus lands on the opener, not just that it left the dialog. - Nested containment across auth states. A logged-in shell may layer a session-timeout modal over an already-open dialog, so escaping one leaves focus inside another. Pin the auth and tenant state, and probe from a known single-modal starting point so nested overlays do not read as an unescapable trap.
Frequently Asked Questions
How is a conforming modal different from a keyboard trap?
A conforming modal deliberately contains focus while open — Tab cycles its own controls so a keyboard user cannot wander behind it — but always provides a way out, typically Escape or a focusable close button that returns focus to the page. A keyboard trap contains focus with no working exit at all. The presence of a functioning release, not the containment itself, is what distinguishes them.
Why press Escape instead of just detecting the loop?
Because detecting the loop alone cannot tell an intentional focus-containing dialog from a genuine trap — both cycle a fixed set of elements. Pressing Escape is the discriminating action: if focus is released back to the document the region was conforming containment, and if Escape does nothing while Tab stays captive it is a real 2.1.2 failure.
What step budget signals a trap versus a large page?
Set the budget above the largest real tab-stop count on any audited route, then treat exhaustion as suspicion rather than proof. The stronger signal is a small distinct-node set repeating within a sliding window — twelve presses touching four or fewer elements — which indicates cycling inside a subset rather than progress through a big page. Confirm either with the Escape probe.