testing and automating accessibility

Asserting Focus Order in Playwright

Focus order is the sequence in which interactive elements receive focus as a user presses Tab. When that sequence is illogical—or when focus silently drops to <body> after a dialog closes or a route changes—keyboard and screen-reader users lose their place entirely. This guide shows how to capture the focus sequence in Playwright, assert it against 2.4.3 Focus Order, and verify focus restoration after overlays and client-side navigation. It deepens one slice of End-to-End Accessibility Testing with Playwright, the workflow that also covers full-page scans and live-region announcements.

WCAG Success Criteria Addressed:

  • 2.4.3 Focus Order
  • 2.1.1 Keyboard
  • 2.4.7 Focus Visible

Why Focus Order Tests Matter:

  • A correct DOM can still produce a broken tab sequence via tabindex or portals.
  • Focus loss after navigation is invisible to static markup checks.
  • Focus restoration is a behavioral contract that only end-to-end tests can prove.
Capturing a focus sequence and verifying focus restoration in Playwright Two bands. The top band captures the focus sequence: reset focus to a known anchor with body.focus(), then a repeated loop that presses Tab, reads document.activeElement, and pushes a label; the labels build up a sequence array which feeds a single assertion using toEqual. The bottom band shows the restoration contract: a focused trigger button opens a dialog with Enter so focus moves inside, then Escape must return focus to the trigger, and must never fall to the document body, which is a hard test failure. Capture the focus sequence Reset focus body.focus() Tab loop · repeat ×N 1 · press Tab 2 · read activeElement 3 · push label sequence[] array grows One assertion toEqual([ … ]) loop back for each Tab press Verify focus restoration Trigger button has focus Dialog opens focus moves inside Focus returns to the trigger Falls to <body> never — a test failure Enter Escape

Prerequisites

Focus-order assertions only mean something when the starting state is deterministic and the elements under test have stable, user-perceivable names.

Implementation Guidelines:

  • Install @playwright/test and, for the cross-check described later, @axe-core/playwright.
  • Anchor every flow to a fixed starting point—Tab advances from wherever focus already sits, so a drifting anchor produces a drifting sequence.
  • Prefer role-based locators (getByRole) and accessible names over CSS selectors, so a captured label reflects what an assistive-technology user actually perceives.
npm install --save-dev @playwright/test @axe-core/playwright
import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  await page.goto('/');
  // Reset focus to the top of the document so every tab sequence is reproducible.
  await page.evaluate(() => document.body.focus());
});

The single-key building block—pressing Tab, activating with Enter/Space, closing with Escape—is covered in keyboard navigation tests in Playwright. This guide assumes those individual presses work and focuses on the sequence they produce.


Capturing the Focus Sequence

The most robust way to assert focus order is to capture which element is focused after each Tab press, then compare the captured sequence to the expected one. You read document.activeElement inside the page and extract a stable identifier.

Implementation Guidelines:

  • Evaluate document.activeElement in the page context after each press and project it to a comparable value (accessible name, data-testid, or tag plus text).
  • Start from a deterministic anchor so the sequence is reproducible.
  • Capture the whole sequence into an array, then assert once—this yields a single, readable diff on failure.
import { test, expect } from '@playwright/test';

// Projects the active element to a stable, human-readable label for comparison.
async function activeLabel(page) {
  return page.evaluate(() => {
    const el = document.activeElement;
    if (!el || el === document.body) return 'BODY';
    return (
      el.getAttribute('aria-label') ||
      el.textContent?.trim() ||
      el.getAttribute('name') ||
      el.tagName
    );
  });
}

test('checkout form follows a logical focus order', async ({ page }) => {
  await page.goto('/checkout');
  await page.evaluate(() => document.body.focus());

  const sequence: string[] = [];
  for (let i = 0; i < 5; i++) {
    await page.keyboard.press('Tab');
    sequence.push(await activeLabel(page));
  }

  // A11y rationale: 2.4.3 Focus Order — the tab path must match the visual
  // and logical reading order of the form.
  expect(sequence).toEqual([
    'Full name',
    'Email',
    'Address',
    'Postal code',
    'Place order',
  ]);
});

Capturing the full array rather than asserting element-by-element gives you a clear diff when the order regresses, pinpointing exactly where the sequence diverged.


Choosing a stable label for each focus stop Three ways to identify the focused element, ranked by how well they survive change. The accessible name is best, because it is what a user perceives and it fails loudly when the name breaks. A test id is stable but invisible to users, so it can pass while the name is missing. The tag name alone is nearly useless, since a page full of buttons produces an unreadable sequence. The accessible name what a user perceives — and the assertion fails when the name breaks A data-testid stable, but invisible to users: it passes even with no name at all The tag name alone a sequence of BUTTON, BUTTON, BUTTON proves almost nothing

Asserting Logical Order Against 2.4.3

2.4.3 Focus Order requires that the focus sequence preserves meaning and operability—it should generally follow the visual reading order. The most common violations are positive tabindex values that yank elements to the front of the sequence, and portal-rendered content (modals, menus) that appears at the end of the DOM but should be reached in context.

Implementation Guidelines:

  • Assert that no element carries a positive tabindex, which is almost always a focus-order smell.
  • Confirm visually adjacent controls are also adjacent in the tab sequence.
  • For portaled overlays, assert focus moves into the overlay when it opens rather than continuing past it.
test('no positive tabindex distorts the focus order', async ({ page }) => {
  await page.goto('/checkout');

  // A11y rationale: 2.4.3 Focus Order — positive tabindex overrides DOM order
  // and almost always breaks the logical sequence.
  const positiveTabindex = await page.evaluate(() =>
    Array.from(document.querySelectorAll('[tabindex]'))
      .filter((el) => Number(el.getAttribute('tabindex')) > 0)
      .map((el) => el.getAttribute('aria-label') || el.tagName),
  );

  expect(positiveTabindex).toEqual([]);
});

For the keyboard-driven flows that feed these order assertions—pressing Tab, activating with Enter/Space, and closing with Escape—see keyboard navigation tests in Playwright.


Verifying Focus Restoration After a Dialog

When a dialog closes, focus must return to the control that opened it. Otherwise the user is dropped at the top of the document—or onto <body>—and must re-traverse the entire page to find their place.

Implementation Guidelines:

  • Record the trigger before opening, open the dialog, and assert focus entered it.
  • Close the dialog and assert focus is back on the exact trigger element.
  • Test closing by every available path (Escape, the close button, the confirm button) since each can restore focus differently.
test('focus returns to the trigger after the dialog closes', async ({ page }) => {
  await page.goto('/account');

  const trigger = page.getByRole('button', { name: 'Edit profile' });
  await trigger.focus();
  await page.keyboard.press('Enter');

  const dialog = page.getByRole('dialog', { name: 'Edit profile' });
  await expect(dialog).toBeVisible();
  await expect(dialog.getByRole('textbox', { name: 'Display name' })).toBeFocused();

  await page.keyboard.press('Escape');
  await expect(dialog).toBeHidden();

  // A11y rationale: 2.4.3 Focus Order — restoring focus to the trigger keeps
  // the user's place instead of dumping them at the top of the document.
  await expect(trigger).toBeFocused();
});

Verifying Focus After Client-Side Navigation

A single-page-app route change swaps the view without a browser load, so focus is not reset by the platform. Without explicit handling, focus stays on a link that may no longer exist or collapses to <body>. The framework must deliberately move focus to the new page's heading or main landmark.

Implementation Guidelines:

  • After navigating by keyboard, assert focus lands on the new h1 or main region.
  • Confirm the target is programmatically focusable (tabindex="-1" on a heading or landmark).
  • Re-run a couple of Tab presses afterward to confirm the sequence continues from the new focus point, not from the top.
test('SPA navigation moves focus to the new page heading', async ({ page }) => {
  await page.goto('/');

  await page.getByRole('link', { name: 'Pricing' }).focus();
  await page.keyboard.press('Enter');

  // A11y rationale: 2.4.3 Focus Order — after a client-side route change,
  // focus must move to a logical landmark in the new view.
  const heading = page.getByRole('heading', { level: 1, name: 'Pricing' });
  await expect(heading).toBeFocused();

  // The next Tab should continue inside the new page, not restart at the top.
  await page.keyboard.press('Tab');
  await expect(page.getByRole('link', { name: 'Compare plans' })).toBeFocused();
});

The framework-side patterns these assertions exercise are covered in Focus Management Strategies for SPAs, and the specific route-change case in handling focus restoration after dynamic route changes.


Focus-order failures ranked by how quietly they ship Three regressions in order of how easily they escape review. A positive tabindex is visible in the markup and caught by lint. A portalled overlay reached at the end of the document is visible only by tabbing. Focus collapsing to the body throws nothing, renders identically and is invisible in every screenshot, which is why it needs an explicit assertion. A positive tabindex visible in the markup, and a lint rule already catches it A portal reached last visible only by tabbing through the page yourself Focus collapsing to <body> throws nothing, looks identical, and needs its own assertion

Detecting Focus Loss to the Body

The most damaging focus bug is silent: focus falls back to document.body, leaving the user with no visible indicator and no obvious next stop. Because nothing throws, this slips through unless you assert against it explicitly.

Implementation Guidelines:

  • After any focus-moving action, assert that document.activeElement is not document.body.
  • Build a small helper and apply it liberally after navigations and overlay closes.
  • Treat a focused body as a hard failure, not a warning.
// Fails loudly when focus has collapsed to the document body.
async function expectFocusNotOnBody(page) {
  const onBody = await page.evaluate(
    () => document.activeElement === document.body || document.activeElement === null,
  );
  expect(onBody, 'focus must not fall back to <body>').toBe(false);
}

test('closing the menu never drops focus to the body', async ({ page }) => {
  await page.goto('/');

  const menuButton = page.getByRole('button', { name: 'Account menu' });
  await menuButton.focus();
  await page.keyboard.press('Enter');
  await expect(page.getByRole('menu')).toBeVisible();

  await page.keyboard.press('Escape');

  // A11y rationale: 2.1.1 Keyboard / 2.4.3 Focus Order — focus must land on a
  // real control, never on the body where the user loses their place.
  await expectFocusNotOnBody(page);
  await expect(menuButton).toBeFocused();
});

How to Verify

  • Manual traversal. Tab through each tested flow by hand and confirm the order, restoration, and navigation focus match your captured sequences. Your eyes are the ground truth for 2.4.3 Focus Order.
  • Screen-reader pass. With NVDA or VoiceOver running, confirm the new page heading is announced after navigation and the dialog name is announced on open—proof that focus moved somewhere meaningful.
  • Visible-ring check. Confirm a visible focus indicator at every captured stop to satisfy 2.4.7 Focus Visible; toBeFocused() cannot see the ring.
  • Regression failure test. Comment out the framework's focus-on-navigation or focus-restoration logic and confirm the relevant test fails by detecting focus on <body>. A test that cannot catch the regression is not protecting you.
  • Axe cross-check. Run @axe-core/playwright to flag missing accessible names that would make your captured labels ambiguous or unstable.

Common Accessibility Mistakes

Even a passing focus-order suite can hide these recurring traps. Watch for them when a test looks green but users still report lost focus.

  • Asserting element-by-element instead of the whole sequence. A per-step assertion fails at the first mismatch and hides where the sequence actually diverged. Capture the full array and assert once so the diff shows the entire path.
  • Trusting DOM order for portaled overlays. Modals and menus often render at the end of <body>, so a naive tab walk reaches them last. Assert that opening the overlay moves focus into it, rather than that it appears in DOM position.
  • Reaching for a positive tabindex to "fix" order. Any tabindex above zero rewrites the global tab sequence and almost guarantees a 2.4.3 violation. Fix the DOM order or use tabindex="0" / -1, never a positive value.
  • Forgetting the reverse direction. Focus order must be logical with Shift+Tab too. Add a backward pass so a control that is skipped only on the return trip does not slip through.
  • Treating toBeFocused() as proof of a visible ring. The assertion reads document.activeElement; it says nothing about whether CSS renders an indicator. Pair it with a manual or visual-regression check for 2.4.7 Focus Visible.

Conclusion

Focus order is a behavioral contract, not a markup property—which is exactly why it belongs in end-to-end tests rather than static scans. Capture the sequence after each Tab, assert the whole array at once, forbid positive tabindex, and prove focus returns to its trigger after a dialog and moves to a landmark after a route change. The single highest-value guard is the explicit "never on <body>" check, because that failure ships silently otherwise. Layer these assertions onto the broader Playwright suite and you turn an invisible, place-losing bug class into a loud, reproducible test failure.


Frequently Asked Questions

How do I read which element is currently focused in Playwright? Evaluate document.activeElement inside the page with page.evaluate() and project it to a stable label—its accessible name, data-testid, or text. Capturing that after each Tab press builds a comparable sequence you can assert against in one expectation.

Why assert against a positive tabindex? Any tabindex greater than zero pulls an element to the front of the global tab order, overriding DOM order and almost always violating 2.4.3 Focus Order. Asserting that no element carries a positive tabindex catches an entire class of order regressions cheaply.

How do I prove focus didn't silently fall to the body? Add an explicit check that document.activeElement is neither document.body nor null after every focus-moving action. This bug throws no error on its own, so without the assertion it ships unnoticed—it is the single most valuable focus-restoration guard.

Where should focus go after a client-side route change? To a logical landmark in the new view—typically the page's h1 or the main region, made focusable with tabindex="-1". Assert focus lands there, then press Tab once more to confirm the sequence continues inside the new page rather than restarting at the top.

Should I also test Shift+Tab order? Yes. 2.4.3 Focus Order applies in both directions, and a control can be reachable forward but skipped on the way back. Run a backward pass with page.keyboard.press('Shift+Tab') and assert the reversed sequence to catch one-directional gaps.