Testing Keyboard Focus Order With Playwright

A scanner can prove that a button has an accessible name. It cannot press Tab. This guide is part of Playwright Accessibility Plugin Integration, and it covers the assertions that only exist when something drives the keyboard: walking a page with page.keyboard.press('Tab'), reading document.activeElement after each press as a stable descriptor, comparing the resulting sequence against an expected order for WCAG 2.2 SC 2.4.3 (Focus Order), catching a focus trap for SC 2.1.2 (No Keyboard Trap), and proving the focus indicator actually changed the pixels for SC 2.4.7 (Focus Visible).

Root Cause

Sequential focus navigation is not encoded anywhere in the DOM. It is computed by the browser from document order, tabindex values, the inert attribute, display and visibility, hidden and content-visibility, the presence of a shadow root’s delegated focus, and — in a handful of cases — a container’s scroll state. Nothing in a static snapshot expresses the resulting sequence, which is why a scanner can flag a positive tabindex value as suspicious but cannot tell you that it moved the filters button four stops earlier and stranded the search field after it. The engine has tabindex and focus-order-semantics rules, and both are heuristics about markup rather than observations of behaviour.

Focus visibility is unobservable for a different reason: the styles that make an indicator exist only apply while the element is focused, and :focus-visible additionally depends on the browser’s modality heuristic — the same element focused by a pointer and by a Tab press can compute different styles. A snapshot scan reads the resting style of every element and sees nothing wrong with outline: 0 in a stylesheet, because a rule cannot know whether some other declaration replaces it on focus. Only a comparison of computed style before and after a real keyboard focus answers the question, and only if the focus arrived by keyboard.

A keyboard trap is temporal in the strictest sense: it is a property of a sequence of presses, not of any single state. Every individual DOM snapshot in a trapped dialog is valid — the elements have names, roles and reachable controls. The failure is that press seven returns to the element from press five. Detecting it requires tabbing past the expected number of stops and noticing that the descriptors repeat, which is a few lines of test code and impossible for any static rule. Together these three criteria are a good illustration of why automated scanning tops out around 30–40% of WCAG: the missing portion is not exotic, it is just behavioural.

Configuration

The foundation is a descriptor that identifies a focus stop stably across refactors. A CSS selector is the wrong key — it changes when a wrapper <div> is added. Role plus accessible name plus test id is the right key, because it is what a screen reader user perceives and it survives DOM churn. The descriptor also carries the viewport geometry and the focused computed style, so one walk feeds all three assertions.

// tests/a11y/focus/descriptor.ts
import type { Page } from '@playwright/test';

export type FocusStop = {
  key: string;          // role + name + test id, the stable identity
  inViewport: boolean;
  top: number;
  outlineWidth: string;
  outlineStyle: string;
  boxShadow: string;
};

export const TABBABLE = [
  'a[href]', 'button:not([disabled])', 'summary',
  'input:not([disabled]):not([type="hidden"])',
  'select:not([disabled])', 'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(', ');

export async function describeFocus(page: Page): Promise<FocusStop | null> {
  return page.evaluate(() => {
    // document.activeElement reports the shadow HOST, never the element
    // focused inside it, so walk down every open shadow root.
    let el = document.activeElement as HTMLElement | null;
    while (el?.shadowRoot?.activeElement) {
      el = el.shadowRoot.activeElement as HTMLElement;
    }
    if (!el || el === document.body) return null; // focus left the document

    const roleOf = (n: Element): string => {
      const explicit = n.getAttribute('role');
      if (explicit) return explicit.split(/\s+/)[0];
      const tag = n.tagName.toLowerCase();
      if (tag === 'a') return n.hasAttribute('href') ? 'link' : 'generic';
      if (tag === 'select') return 'combobox';
      if (tag === 'textarea') return 'textbox';
      if (tag === 'summary') return 'button';
      if (tag !== 'input') return tag;
      const type = (n.getAttribute('type') ?? 'text').toLowerCase();
      const map: Record<string, string> = {
        checkbox: 'checkbox', radio: 'radio', range: 'slider',
        button: 'button', submit: 'button', reset: 'button', search: 'searchbox',
      };
      return map[type] ?? 'textbox';
    };

    // A pragmatic accessible name, not a full spec implementation: enough to
    // identify the stop, and it matches what getByRole would look up.
    const nameOf = (n: HTMLElement): string => {
      const label = n.getAttribute('aria-label');
      if (label?.trim()) return label.trim();
      const ids = n.getAttribute('aria-labelledby');
      if (ids) {
        const text = ids.split(/\s+/)
          .map((id) => n.ownerDocument.getElementById(id)?.textContent ?? '')
          .join(' ').replace(/\s+/g, ' ').trim();
        if (text) return text;
      }
      const labels = (n as HTMLInputElement).labels;
      if (labels?.length) return (labels[0].textContent ?? '').trim();
      const fallback = n.getAttribute('alt') ?? n.getAttribute('title')
        ?? n.textContent ?? '';
      return fallback.replace(/\s+/g, ' ').trim().slice(0, 60);
    };

    const cs = getComputedStyle(el);
    const box = el.getBoundingClientRect();
    const testId = el.getAttribute('data-testid');

    return {
      key: `${roleOf(el)} "${nameOf(el)}"${testId ? ` #${testId}` : ''}`,
      // A focused element must be at least partly inside the viewport.
      inViewport: box.width > 0 && box.height > 0
        && box.bottom > 0 && box.right > 0
        && box.top < window.innerHeight && box.left < window.innerWidth,
      top: Math.round(box.top),
      outlineWidth: cs.outlineWidth,
      outlineStyle: cs.outlineStyle,
      boxShadow: cs.boxShadow,
    };
  });
}

export async function tabThrough(page: Page, presses: number) {
  const stops: (FocusStop | null)[] = [];
  for (let i = 0; i < presses; i += 1) {
    await page.keyboard.press('Tab');
    stops.push(await describeFocus(page));
  }
  return stops;
}

Seeding matters more than it looks. Focus must start from a known place, which on a freshly loaded page means pressing Tab immediately after goto and before any click — a click moves focus to whatever was clicked and every subsequent expectation shifts by an unknown offset. Where a test needs to start mid-page, call .focus() on a named landmark link and count from there rather than clicking.

// tests/a11y/focus/tab-order.spec.ts
import { test, expect } from '@playwright/test';
import { describeFocus, tabThrough, TABBABLE } from './descriptor';

// The contract, written the way a screen reader announces it.
const EXPECTED = [
  'link "Skip to content"',
  'link "Home"',
  'button "Search"',
  'textbox "Query"',
  'button "Filters"',
  'link "Help"',
];

test('header tab order, traps and off-screen focus', async ({ page }) => {
  await page.goto('/products');
  await page.getByRole('heading', { level: 1 }).waitFor();

  // Resting styles first: nothing is focused yet, so this is the baseline.
  const resting = await page.$$eval(TABBABLE, (nodes) =>
    nodes.map((n) => {
      const cs = getComputedStyle(n);
      return { outlineWidth: cs.outlineWidth, boxShadow: cs.boxShadow };
    }),
  );

  // Two presses past the contract: enough to see a wrap or a trap.
  const stops = await tabThrough(page, EXPECTED.length + 2);

  // SC 2.4.3 Focus Order — the sequence itself.
  expect(stops.slice(0, EXPECTED.length).map((s) => s?.key)).toEqual(EXPECTED);

  // SC 2.1.2 No Keyboard Trap — focus must leave the group or wrap to stop 1.
  const tail = stops.slice(EXPECTED.length);
  const escaped = tail.some((s) => s === null || s.key === stops[0]?.key);
  expect(escaped, `focus never escaped: ${tail.map((s) => s?.key).join(' -> ')}`)
    .toBe(true);

  // Off-screen focus: a focused control the user cannot see.
  for (const stop of stops.filter(Boolean)) {
    expect(stop!.inViewport, `${stop!.key} focused at y=${stop!.top}`).toBe(true);
  }

  // SC 2.4.7 Focus Visible — the pixels must differ from the resting state.
  const restingKeys = new Set(resting.map((r) => `${r.outlineWidth}|${r.boxShadow}`));
  for (const stop of stops.slice(0, EXPECTED.length).filter(Boolean)) {
    const focused = `${stop!.outlineWidth}|${stop!.boxShadow}`;
    const invisible = stop!.outlineStyle === 'none' && stop!.boxShadow === 'none';
    expect(invisible, `${stop!.key} has no outline and no box-shadow`).toBe(false);
    expect(restingKeys.has(focused) && invisible, `${stop!.key} looks unfocused`)
      .toBe(false);
  }
});
Expected tab sequence against the observed sequence The left column lists the six expected focus stops in order: skip link, home link, search button, query textbox, filters button and help link. The right column lists the observed order in which the filters button appears second, and connectors mark the four stops whose positions changed. expected order observed order 1 link Skip to content 2 link Home 3 button Search 4 textbox Query 5 button Filters 6 link Help 1 link Skip to content 2 button Filters 3 link Home 4 button Search 5 textbox Query 6 link Help One tabindex="3" moved four stops; a scanner reports the attribute, never the sequence.
The assertion compares two lists of role-and-name strings, so the failure message reads like the sequence a screen reader would announce.

The trap assertion deserves its own explanation, because “focus escaped” has two legitimate shapes. On a page, Tab past the last control moves focus out of the document entirely — the descriptor comes back null because activeElement has fallen through to body — or wraps back to the first stop after the browser chrome is skipped. Inside a modal dialog, neither is correct: a well-built dialog should cycle within itself while it is open, and it must release focus when dismissed. So the same walk is asserted differently by context: on a page, escape or wrap is required; in an open dialog, a cycle is required, plus a second walk after pressing Escape that proves focus returned to the trigger.

Two presses past the contract separates a wrap from a trap Rows for Tab presses four to eight show that on a healthy page focus moves from the filters button to the help link, then out to browser chrome, then back to the skip link and the home link, while in a trapped dialog focus alternates between a coupon textbox and an apply button forever. What the two extra presses reveal Tab press healthy page: focus lands on trapped dialog: focus lands on 5 button "Filters" textbox "Coupon code" 6 link "Help" — last expected stop button "Apply" 7 null — focus left the document textbox "Coupon code" again 8 link "Skip to content" — wrapped button "Apply" again escaped or wrapped: SC 2.1.2 pass two-element cycle: SC 2.1.2 fail Inside a dialog the verdicts invert: a cycle is required, and release is asserted after Escape.
Two presses beyond the contract is the smallest window that distinguishes a legitimate wrap from a trap, and it costs two keystrokes.

Validation

Prove each assertion catches its own failure before trusting a green run. Add tabindex="3" to the filters button and the order assertion fails with two readable lists rather than a selector diff:

npx playwright test tests/a11y/focus --reporter=list
# 1) header tab order, traps and off-screen focus ─────────────────────
#    expect(received).toEqual(expected)
#    - Expected  - 2
#    + Received  + 2
#      [
#        "link \"Skip to content\"",
#    -   "link \"Home\"",
#    +   "button \"Filters\"",
#        ...
#      ]

Then break each of the other two deliberately. Add outline: 0 to the search button with no replacement and the focus-visible assertion reports button "Search" has no outline and no box-shadow. Leave an off-canvas navigation panel at left: -100vw while its links stay tabbable and the viewport assertion reports link "Account" focused at y=214 with inViewport false — the classic failure where a closed hamburger menu keeps six invisible tab stops. For the trap, remove the Escape handler from a dialog and watch the tail print textbox "Coupon code" -> button "Apply" with no null and no wrap.

Edge Cases and Conditional Guards

  • Shadow roots and iframes need different traversal. The descriptor already walks open shadow roots, because document.activeElement stops at the host. A closed shadow root is opaque and its internal stops are invisible to this technique — assert them in a component test instead. An iframe reports the <iframe> element itself as the active element, so a page with embedded content needs the walk repeated per frame via page.frames(), with the frame’s own activeElement read inside it.
  • Composite widgets expose one tab stop, not many. A grid, a toolbar, a tablist or a tree implementing roving tabindex correctly contributes exactly one entry to the expected order, and its internal movement is arrow-key behaviour asserted separately. Listing every cell in EXPECTED will fail against a correct implementation, which is a good way to have the whole test deleted. Widget-contract assertions of this kind pair well with writing a custom axe rule for a combobox pattern, which enforces the static half of the same contract.
  • Modality changes what :focus-visible matches. Arriving at an element by pressing Tab guarantees keyboard modality, which is exactly why the walk reads the style at each stop instead of calling .focus() on a locator. A programmatic .focus() after a pointer interaction may compute no indicator in a design that styles :focus-visible alone, producing a false failure. Never mix the two approaches inside one assertion.

Pipeline Impact

These are ordinary Playwright assertions, so the gate is the same exit code the scan job already uses and no new infrastructure is needed. The cost profile is different from a scan, though: a walk of twenty stops is twenty press calls plus twenty evaluate round trips, roughly 40–60 ms of protocol traffic on a local runner, so a focus test is cheaper than an axe scan of the same page rather than more expensive. Keep the focus specs in the same accessibility project as the scans so both run under one required check, and let the trace artifact do the debugging — a Playwright trace records the DOM at each action, so a reviewer can step through the presses and see where focus went without reproducing anything.

The failure mode to plan for is brittleness in EXPECTED. Any header redesign changes the list, and a test that has to be edited on every unrelated pull request will be deleted. Two mitigations work: keep each expected list short and scoped to one region rather than the whole page, and treat the list as a reviewed contract owned by the same people who own the component, with a CODEOWNERS entry so a change to it is seen. Making the check required in branch protection is worth doing only once the lists are stable, following the same policy as any other accessibility status check described in requiring accessibility status checks in branch protection.

Two geometric and stylistic checks on one focused element On the left a viewport rectangle contains an in-view focused control at y equals 210 while a second focused control sits below the viewport at y equals 1180. On the right a table shows outline width changing from zero to three pixels and box shadow from none to a four pixel ring, with a pass verdict for focus visible. Is it on screen, and did anything change? viewport 1280 x 720 focused, in view box.top = 210 viewport bottom focused, off-canvas box.top = 1180, fail property resting focused outline-width 0px 3px box-shadow none 4px ring something changed: SC 2.4.7 pass a change is necessary, not sufficient: contrast and area still need judging Both facts come from the same walk, so neither check adds a browser round trip of its own.
The style comparison proves an indicator exists; whether it is big enough and contrasty enough remains a manual judgement.

Common Pitfalls

  • Clicking anything before the first Tab press, which reseeds focus and shifts every subsequent expectation by an unknown number of stops.
  • Keying the expected order on CSS selectors, so an added wrapper element breaks a test that describes behaviour rather than structure.
  • Asserting only that a focus ring exists, and concluding SC 2.4.7 is met — a 1px indicator at 1.4:1 against its background changed the pixels and still fails a human.
  • Reading document.activeElement without descending into open shadow roots, which reports the custom element host for every stop inside a design-system component and makes six different controls look identical.
  • Tabbing exactly as many times as the expected list is long, which can never observe a wrap or a trap because the walk stops at the last legitimate stop.
  • Treating a dialog like a page and asserting that focus escapes it, which fails a correctly implemented modal and teaches the team the test is wrong.

FAQ

Why not use the accessibility tree snapshot instead of a hand-rolled descriptor? The tree snapshot is the right tool for asserting names and roles across a whole region at once, and it is worth having for exactly that — the approach is covered in asserting accessibility tree names with Playwright snapshots. It says nothing about tab order, because the tree is a structural projection of the DOM and sequential focus navigation is not part of it. The descriptor here exists to name one element, the currently focused one, in a form that reads like an announcement.

How many stops should one expected list cover? Enough to cover one region — a header, a form, a dialog — and rarely more than about a dozen. A whole-page list on a real application runs to sixty entries, breaks on every content change, and produces a diff nobody reads. Several short region-scoped lists catch the same regressions, fail with an obvious owner, and can be added incrementally as each region is remediated.

Does this replace manual keyboard testing? No, and it should not be sold as doing so. These assertions lock in decisions someone already made about a specific region: this is the order, focus must not be trapped here, an indicator must appear. A human still has to judge whether the order makes sense for the task, whether the indicator is discernible against a photographic background, and whether a custom widget’s arrow-key behaviour matches user expectations. The value is that once a human has judged it, the judgement stops regressing — the same reason a route-change focus assertion is worth writing, as in testing focus management after client-side route changes.