Keyboard and Focus Order Auditing
A static rule engine can tell you an element has an accessible name; it cannot tell you whether a keyboard user can ever reach that element, what order focus lands on the controls, or whether pressing Tab inside a modal escapes back into the page. Those are interaction-time facts — they only exist once something drives the browser, presses Tab and Shift+Tab, and reads document.activeElement after each press. This guide, part of the Enterprise WCAG Audit Architecture & Standards Mapping section, covers how to automate the operable and focus success criteria — WCAG 2.1.1 (keyboard), 2.4.3 (focus order), 2.4.7 (focus visible), and 2.1.2 (no keyboard trap) — by scripting Tab traversal, recording the focus path, asserting DOM order against visual order, and detecting elements that are unreachable or that trap focus.
The honest framing matters here: this is a complement to the static axe gate, not a replacement for it, and not everything on this list is fully machine-decidable. A script can prove that focus moves and escapes; it can measure the geometry of the focus ring; it cannot reliably judge whether a 1px hairline outline against a busy background is perceptibly visible. So the automated layer establishes a hard floor — reachability, order, trap-freedom — and flags the residue for human review rather than pretending to certify it.
Prerequisites & Environment Parity
Keyboard traversal is stateful in a way that a one-shot axe scan is not: every result depends on where focus started and what the previous key press did. Pin the runtime so that the sequence is reproducible.
- Playwright for Python, pinned (e.g.
playwright==1.44.*) with the browser installed viaplaywright install --with-deps chromium. Keyboard event dispatch and focus reporting must come from the same engine build everywhere, because focus behavior for<div tabindex>and disabled controls has genuinely differed between Chromium versions. The launch and context mechanics are covered in the Playwright headless scanning workflows guide; this page assumes you already have a stabilized page object. - A settled, hydrated DOM before the first
Tab. A framework that attaches focus handlers or injects a focus-trap library after hydration will produce a different traversal than the pre-hydration skeleton. Wait for the app-ready signal, not justload. - A fixed viewport (e.g.
1280x800). Focus order is stable across viewports, but visual order is not — a two-column layout that reflows to one column at a narrow width changes the reading order you assert against. Audit each breakpoint you claim to support. prefers-reduced-motionand headless-vs-headed parity. Focus-visible styles sometimes hang off:focus-visible, whose heuristic differs slightly for programmatic vs real key input. Drive focus with realkeyboard.press("Tab")calls, never by calling.focus()in JavaScript, or:focus-visiblemay not match and 2.4.7 checks will read false.
Treat the keyboard suite as a separate job from the axe gate. It is slower (it steps through the page one key at a time), it fails for different reasons, and its findings route to different owners. Keep the two contract-separated so a flaky traversal never masks a clean axe result or vice versa.
Conceptual model: the focus walk as a state machine
Automating these criteria comes down to one primitive — the focus walk — and a set of assertions layered on top of it. You press Tab, read the newly focused element, record it, and repeat until focus either returns to where it started (a clean cycle through the document) or stops advancing (a trap). Each of the four success criteria is a different question asked about the same recorded walk:
- 2.1.1 keyboard — did every interactive element appear in the walk at all? An element that never becomes
document.activeElementacross a full traversal is unreachable by keyboard. - 2.4.3 focus order — did the elements appear in an order that matches the meaningful reading/visual order? This is a comparison between the recorded sequence and an expected sequence.
- 2.1.2 no keyboard trap — did the walk ever stop advancing, cycling among a subset of nodes with no key able to move focus out? That is a trap.
- 2.4.7 focus visible — when each element was focused, did a visible indicator appear? Partially scriptable (is there any computed style delta on focus?), partially human (is that delta perceptible?).
The walk itself is a small state machine. The diagram below shows the loop and the three terminal states it can reach — a clean cycle, a detected trap, or an exhausted step budget — which is the backbone every check in this section builds on.
Two subtleties break naive implementations. First, Tab from the last focusable element in the document moves focus to the browser chrome (the URL bar), where document.activeElement reports <body> — so “focus left the document” is a legitimate terminal, not a trap. Second, custom widgets that implement a roving tabindex (a toolbar, a radio group, a tree) intentionally expose only one tab stop and move between children with arrow keys. A focus walk that only presses Tab will see one stop and must not conclude the other children are unreachable; arrow-key traversal is a separate check.
Step-by-Step Implementation
The following builds a reusable focus-walk primitive and layers the four checks on it. Each step is independently testable against a fixture.
1. Capture a stable identity for the focused element
document.activeElement is a live DOM node; you cannot serialize it across the CDP boundary. Extract a stable, human-readable descriptor instead — tag, role, accessible name, and a CSS path — so the recorded walk is inspectable and diffable.
# focus_probe.py — read a serializable descriptor of the current focus.
from playwright.async_api import Page
ACTIVE_DESCRIPTOR = """
() => {
const el = document.activeElement;
if (!el || el === document.body) return null; // focus left the document
const name = el.getAttribute('aria-label')
|| el.textContent?.trim().slice(0, 40)
|| el.getAttribute('name') || '';
// Build a short CSS path so triage can relocate the node.
const path = el.id ? `#${el.id}`
: el.tagName.toLowerCase()
+ (el.className && typeof el.className === 'string'
? '.' + el.className.trim().split(/\\s+/).join('.') : '');
return {
tag: el.tagName.toLowerCase(),
role: el.getAttribute('role') || null,
name,
path,
tabindex: el.getAttribute('tabindex'),
};
}
"""
async def focused(page: Page) -> dict | None:
"""Return a serializable descriptor of document.activeElement, or None."""
return await page.evaluate(ACTIVE_DESCRIPTOR)2. Walk the document with Tab and record the path
Press Tab in a loop, reading focus after each press. Terminate on any of three conditions — focus returns to the first stop, focus leaves the document, or a step budget is exhausted — and return both the ordered path and why the walk ended, because the termination reason is itself the signal for later checks.
# focus_walk.py — the core primitive every keyboard check builds on.
from dataclasses import dataclass, field
from playwright.async_api import Page
from focus_probe import focused
@dataclass
class WalkResult:
path: list[dict] = field(default_factory=list)
reason: str = "" # "cycled" | "left_document" | "budget"
async def tab_walk(page: Page, max_steps: int = 250) -> WalkResult:
# Start from a known anchor so the walk is deterministic run to run.
await page.locator("body").press("Tab")
first = await focused(page)
result = WalkResult(path=[first] if first else [])
for _ in range(max_steps):
await page.keyboard.press("Tab")
current = await focused(page)
if current is None:
result.reason = "left_document" # reached browser chrome — OK
return result
if result.path and current["path"] == result.path[0]["path"]:
result.reason = "cycled" # full loop back to the start
return result
result.path.append(current)
result.reason = "budget" # never settled — inspect it
return result3. Assert reachability for known interactive elements (2.1.1)
Collect every element the DOM considers interactive, then assert each one appears in the walk. The gap set — interactive elements that never received focus — is your 2.1.1 failure list.
# reachability.py — every interactive element must appear in the focus walk.
INTERACTIVE = (
"a[href], button, input:not([type=hidden]), select, textarea, "
"[tabindex]:not([tabindex='-1']), [role=button], [role=link], [role=menuitem]"
)
async def unreachable_elements(page, walk) -> list[str]:
reached = {node["path"] for node in walk.path}
candidates = await page.eval_on_selector_all(
INTERACTIVE,
"els => els.filter(e => e.offsetParent !== null)" # visible only
".map(e => e.id ? '#'+e.id : e.tagName.toLowerCase())",
)
# Anything interactive and visible that the Tab walk never landed on.
return [sel for sel in candidates if sel not in reached]4. Compare focus order against expected visual order (2.4.3)
Focus order is a failure when the tab sequence contradicts the meaningful reading order. The scriptable proxy is geometry: for a single-column layout, focus should generally advance top-to-bottom, left-to-right. Flag backward jumps for review rather than auto-failing, since multi-column and grid layouts have legitimate non-linear orders. The dedicated technique for asserting against an explicit expected order lives in automating keyboard focus order checks.
# order_check.py — flag focus-order jumps that fight the visual reading order.
async def order_anomalies(page, walk) -> list[dict]:
boxes = []
for node in walk.path:
box = await page.locator(node["path"]).bounding_box()
if box:
boxes.append((node, box))
anomalies = []
for (prev, pb), (cur, cb) in zip(boxes, boxes[1:]):
# A large upward jump not explained by a new column is suspicious.
moved_up = cb["y"] < pb["y"] - 24
same_column = abs(cb["x"] - pb["x"]) < 40
if moved_up and same_column:
anomalies.append({"from": prev["path"], "to": cur["path"]})
return anomalies5. Detect traps and containment (2.1.2)
A walk that ends with reason == "budget" never cycled and never left the document — the strongest automated signal of a keyboard trap. Distinguishing a real trap from a correctly contained modal (which is allowed to hold focus as long as Escape releases it) needs a second probe; the full method is in detecting focus traps in automated audits.
# trap_signal.py — turn a walk's termination reason into a trap verdict.
def trap_suspected(walk) -> bool:
# Budget exhaustion on a page with far fewer real controls than the
# step budget means focus was cycling inside a subset — a trap.
if walk.reason == "budget":
return True
# A short cycle length that keeps repeating the same few nodes is also
# suspicious even if it technically "cycled".
return FalseConfiguration Reference
The knobs below tune the traversal. They belong in a committed config so a walk is reproducible across laptop and CI.
| Setting | Type | Default | Description |
|---|---|---|---|
max_steps |
int | 250 |
Upper bound on Tab presses before the walk gives up and flags a suspected trap. Set above the largest real tab-stop count on any audited route. |
start_anchor |
CSS selector | body |
Element focus is reset to before the walk begins, so every run starts from the same place. |
viewport |
{width, height} |
1280x800 |
Fixed viewport; visual-order assertions are only valid at the breakpoint they were recorded for. |
include_reverse |
bool | true |
Also walk with Shift+Tab to confirm backward order mirrors forward order and no reverse-only trap exists. |
arrow_widgets |
list of selectors | [] |
Roving-tabindex containers to traverse with arrow keys instead of Tab, so their children are not falsely reported unreachable. |
visible_only |
bool | true |
Restrict the reachability candidate set to elements with a layout box, excluding display:none controls. |
order_jump_threshold |
int (px) | 24 |
Minimum upward pixel jump, within the same column, that counts as a focus-order anomaly worth review. |
Verification & Testing
Prove each check against fixtures with known outcomes before trusting it on real routes. Three fixtures lock the contract: a clean page whose visual order matches its DOM order, a page with a tabindex="3"-style positive-tabindex reorder that must trip the order check, and a page with a focus trap that must trip the trap signal.
import pytest
@pytest.mark.asyncio
async def test_clean_page_cycles_and_reaches_all(page):
"""A well-built page: the walk cycles and no control is unreachable."""
await page.goto("http://localhost:8080/fixtures/clean-order.html")
walk = await tab_walk(page)
assert walk.reason in ("cycled", "left_document")
assert await unreachable_elements(page, walk) == []
@pytest.mark.asyncio
async def test_positive_tabindex_trips_order_check(page):
"""A positive tabindex jumps focus out of reading order (SC 2.4.3)."""
await page.goto("http://localhost:8080/fixtures/positive-tabindex.html")
walk = await tab_walk(page)
assert await order_anomalies(page, walk) != []
@pytest.mark.asyncio
async def test_trap_fixture_never_cycles(page):
"""A trapping widget exhausts the step budget (SC 2.1.2)."""
await page.goto("http://localhost:8080/fixtures/keyboard-trap.html")
walk = await tab_walk(page, max_steps=60)
assert trap_suspected(walk)In CI, run the keyboard suite as its own job alongside the axe gate, uploading the recorded focus path as an artifact so a failure shows the exact sequence rather than a bare assertion. Blocking findings feed the same triage flow as the rest of the pipeline; the criteria this suite covers, and which of them remain manual, are catalogued in the WCAG 2.2 vs 3.0 success criteria taxonomy. Where a component’s interactive boundary is ambiguous, reconcile it against dynamic content boundary detection so the walk does not spill into a third-party embed.
Failure Modes & Troubleshooting
Programmatic .focus() instead of real key presses. Symptom: 2.4.7 focus-visible checks report no indicator even though one appears when you Tab by hand. Root cause: calling element.focus() in JS does not always match :focus-visible, whose heuristic favors keyboard input. Fix: drive every focus change with page.keyboard.press("Tab") so the browser applies its real keyboard-origin styling.
Roving-tabindex children reported unreachable. Symptom: a toolbar or radio group flags every child but the first as a 2.1.1 failure. Root cause: the widget exposes one tab stop by design and moves internally with arrow keys, which a Tab-only walk never exercises. Fix: register those containers in arrow_widgets and traverse them with ArrowRight/ArrowDown, asserting reachability within the widget separately.
Walk terminates in browser chrome and is misread as a trap. Symptom: short pages intermittently report reason == "budget" or an incomplete path. Root cause: Tab from the last control moved focus to the URL bar, activeElement became <body>, and the loop was not treating that as a clean exit. Fix: treat focused() is None as the legitimate left_document terminal, and only suspect a trap on genuine budget exhaustion.
Non-deterministic order from an unsettled DOM. Symptom: the recorded path differs between runs of the same commit. Root cause: a focus-trap library or lazy-hydrated control attached after the first Tab. Fix: wait for the app-ready signal before walking, and assert the tab-stop count is stable across two back-to-back walks before trusting the order.
Modal containment flagged as a hard trap. Symptom: an intentionally focus-contained dialog fails 2.1.2. Root cause: the check saw focus cycling within the dialog and stopped there. Fix: on a suspected trap, press Escape and confirm focus returns to the invoking control — a contained modal that releases on Escape conforms; one that does not is the real failure.
Frequently Asked Questions
Why can't axe-core catch a keyboard trap on its own?
axe evaluates a static DOM snapshot at a single instant; a trap only exists as a consequence of pressing Tab and observing that focus never escapes. There is no attribute or computed style that marks a region as trapping — you have to actually drive the keyboard and watch document.activeElement fail to advance. That is a scripted interaction, which is outside what a rule engine that never presses a key can decide.
What is the difference between focus order and tab order?
Tab order is the raw sequence in which pressing Tab moves focus, determined by DOM order and tabindex values. Focus order (SC 2.4.3) is the requirement that this sequence preserve meaning — that the order a keyboard user encounters controls matches the reading and operational order a sighted user perceives. Every focus-order failure is a tab order that technically works but contradicts the visual layout, most often caused by positive tabindex values.
How do I test that a skip link actually works?
Load the page, press Tab once so the skip link receives focus, press Enter to activate it, then read document.activeElement and confirm focus landed on (or just before) the main content container rather than staying at the top. A skip link that visually exists but moves focus nowhere — because its target has no tabindex="-1" and cannot receive focus — passes a static check but fails this interaction test.
How should an automated walk handle a roving tabindex widget?
Treat it as a single tab stop for the Tab walk and traverse its children with arrow keys in a separate pass. A correctly built roving-tabindex toolbar exposes exactly one element with tabindex="0" and the rest with tabindex="-1"; Tab enters and leaves the widget once, while ArrowRight/ArrowLeft move the active child. Asserting reachability of the children with Tab would wrongly fail them.
Can automation confirm the focus indicator is visible enough for SC 2.4.7?
Only partially. A script can confirm that focusing an element produces some change in computed style — an outline, box-shadow, or background — which catches the outright outline: none failures. It cannot judge whether that indicator has sufficient contrast and size to be perceptible against real content, which WCAG’s focus-appearance criteria quantify. Treat the automated result as a floor and route borderline indicators to human review.