Automating Keyboard Focus Order Checks

To assert WCAG 2.4.3 focus order automatically, press Tab through a page with Playwright, collect the ordered list of elements that received focus, and compare that recorded sequence against an expected order derived from the page’s visual reading order — failing when the two diverge. This page shows how to build that comparison so a positive-tabindex regression or a DOM/CSS reorder is caught as a deterministic diff rather than left to a manual sweep.

This is a focused technique within the Keyboard and Focus Order Auditing guide, which sits under the Enterprise WCAG Audit Architecture & Standards Mapping section. The parent guide covers the full focus walk and all four operable criteria; this page narrows to one of them — proving the order of focus is meaningful, not merely that focus moves.

When This Applies

Use an ordered focus-sequence assertion when the failure you are guarding against is sequence, not reachability. The conditions are specific:

  • The layout separates DOM order from visual order — a CSS grid, order, flex-direction: row-reverse, or absolutely positioned controls — so an element can be reachable yet reached at the wrong time.
  • Someone has used positive tabindex values (tabindex="1", tabindex="2") to “fix” a tab sequence, which globally reprioritizes focus and almost always produces a 2.4.3 failure elsewhere.
  • A component renders visually before another but appears later in the DOM (or vice versa), so keyboard users traverse it in a jarring order.

If your problem is instead that a control never receives focus at all, that is a 2.1.1 reachability check, not a 2.4.3 order check — the two use the same walk but different assertions. And if focus never advances, see the parent guide’s trap handling first; you cannot assert order on a walk that never completed.

Minimal Reproducible Example

The tempting shortcut is to read tabindex attributes off the DOM statically and assume that tells you the order. It does not — the effective tab order is a function of DOM position and tabindex together, and it ignores CSS entirely, so a static read misses the most common real failures.

# order_naive.py — DO NOT TRUST. Reads tabindex statically and ignores CSS.
els = page.query_selector_all("[tabindex], a, button, input")
order = sorted(els, key=lambda e: int(e.get_attribute("tabindex") or 0))
# Wrong: this ignores DOM order for tabindex=0 elements, ignores CSS-driven
# visual reordering, and never actually presses Tab — so it cannot see the
# order a keyboard user really experiences.

This never presses a key, so it cannot observe the browser’s real focus algorithm, and it compares nothing to the visual order, which is where 2.4.3 failures actually live.

Correct Implementation

Drive the browser with real Tab presses to record the true focus sequence, independently compute the expected order from on-screen geometry, then diff the two. The divergence points are your 2.4.3 findings.

# focus_order_check.py — record real Tab order, compare to visual order.
from playwright.async_api import Page

DESCRIBE = """
() => {
  const el = document.activeElement;
  if (!el || el === document.body) return null;
  const r = el.getBoundingClientRect();
  return {
    key: el.id || el.getAttribute('aria-label') || el.textContent?.trim().slice(0,30),
    x: Math.round(r.x), y: Math.round(r.y),
  };
}
"""

async def recorded_order(page: Page, max_steps: int = 200) -> list[dict]:
    """The true focus sequence, as produced by pressing Tab."""
    await page.locator("body").press("Tab")
    seq, first = [], await page.evaluate(DESCRIBE)
    seq.append(first)
    for _ in range(max_steps):
        await page.keyboard.press("Tab")
        node = await page.evaluate(DESCRIBE)
        if node is None or (seq and node["key"] == seq[0]["key"]):
            break                       # left the document or cycled back
        seq.append(node)
    return seq

def expected_visual_order(seq: list[dict]) -> list[dict]:
    """Reading order: top-to-bottom, then left-to-right within a row band."""
    # Group into horizontal bands (~same y), then sort each band by x.
    return sorted(seq, key=lambda n: (round(n["y"] / 24), n["x"]))

def order_findings(seq: list[dict]) -> list[dict]:
    expected = expected_visual_order(seq)
    findings = []
    for i, (got, want) in enumerate(zip(seq, expected)):
        if got["key"] != want["key"]:
            # Focus reached `got` where the visual reading order expected `want`.
            findings.append({"step": i, "focused": got["key"], "expected": want["key"]})
    return findings

The comparison is deliberately geometry-driven: the expected order is computed from what is on screen, not hand-maintained, so it survives content changes. The band size (24px here) tolerates minor vertical misalignment between controls that a reader treats as the same row. For pages with a legitimately non-linear layout — a masonry grid, a multi-column form — replace expected_visual_order with an explicit per-route expected key list committed alongside the test, so the “expected” reflects the design intent rather than a naive top-left scan.

Pipeline Integration Note

This check runs inside the keyboard job described in the parent guide, as a distinct step after the reachability assertion and before the trap probe — order is only meaningful once you know the walk completed and every control was reached. It consumes the same recorded focus path, so there is no second traversal cost. Findings carry a focused/expected pair that maps cleanly onto a triage ticket, feeding the same routing the rest of the audit uses. Because the expected order is derived from viewport geometry, run the check at each supported breakpoint; a layout that reflows from two columns to one legitimately changes the reading order, and reconciling that boundary is part of dynamic content boundary detection. The traversal harness itself is provisioned by the Playwright headless scanning workflows setup.

Gotchas

  • Positive tabindex reorders the whole document, not just its element. A single tabindex="1" pulls that control to the front of the entire tab sequence ahead of every tabindex="0" and native control. The finding shows up as a jump at step 0, not near the offending element — assert on the full sequence, not on the neighborhood of the changed component.
  • Authenticated and multi-tenant shells expose different controls. A logged-out marketing shell and a logged-in app render a different set of tab stops, so an expected-order list captured against one tenant will mis-fire on another. Capture the storage state and pin the tenant before recording the baseline order.
  • Sticky headers and off-screen carousels distort the geometry. A control that is visually offscreen (a carousel slide at x: 2000) or pinned by position: sticky reports coordinates that break the naive band sort. Filter to elements within the viewport, or fold offscreen regions into their own expected sub-order.

Frequently Asked Questions

Why not just read tabindex values instead of pressing Tab?

Because the effective tab order combines DOM position with tabindex and is unaffected by the CSS that drives visual order — so a static read of tabindex tells you neither the real focus sequence nor whether it matches what a user sees. Only pressing Tab and reading document.activeElement reproduces the browser’s actual focus algorithm, including how it interleaves positive-tabindex elements with the rest.

How do I define the expected order for a complex grid layout?

Replace the geometric top-to-bottom heuristic with an explicit list of element keys committed next to the test, representing the design’s intended reading order. The check then diffs the recorded sequence against that authored list. This is more maintenance but is the only defensible expected order for masonry, multi-column, or reordered layouts where a naive top-left scan does not reflect intent.

Should a focus-order mismatch fail the build or warn?

Treat a mismatch against an explicit authored expected order as a hard failure, since it encodes a decision. Treat a mismatch against the geometric heuristic as a warning that routes to review, because legitimate multi-column layouts trip it. Which mode you use depends on whether you committed an expected order for that route.

Does the check need to run with Shift+Tab too?

Yes, for full coverage. Reverse traversal should visit the same elements in mirror order; a sequence that differs meaningfully forward versus backward indicates a scripted focus manager interfering with native order. Record both walks and assert the reverse path is the forward path reversed, allowing for documented exceptions.