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.
WCAG Success Criteria Addressed:
2.4.3 Focus Order2.1.1 Keyboard2.4.7 Focus Visible
Why Focus Order Tests Matter:
- A correct DOM can still produce a broken tab sequence via
tabindexor 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 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.activeElementin 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.
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
h1ormainregion. - Confirm the target is programmatically focusable (
tabindex="-1"on a heading or landmark). - Re-run a couple of
Tabpresses 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.
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.activeElementis notdocument.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/playwrightto flag missing accessible names that would make your captured labels ambiguous or unstable.
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.
Related guides
- End-to-End Accessibility Testing with Playwright — the guide for scans, focus, and announcements.
- Focus Management Strategies for SPAs — the framework patterns these assertions verify.
- Keyboard Navigation Tests in Playwright — tab, activate, and Escape flows that feed focus-order assertions.