PRACTICAL GUIDE / Playwright focus order testing
Catch broken tab order and keyboard traps with Playwright
Build Playwright tests that follow real Tab and Shift+Tab movement, expose broken focus order, distinguish modal containment, and catch keyboard traps.
In this guide7 sections
- Follow browser focus instead of guessing from the DOM
- Prove a short path that matters to the user
- Detect a trap with a bounded, explainable search
- Treat modal containment and keyboard traps as different claims
- Read the trace and event sequence before rewriting selectors
- Roll out coverage where it protects meaning
- Know when exact focus order is the wrong test
What you will learn
- Follow browser focus instead of guessing from the DOM
- Prove a short path that matters to the user
- Detect a trap with a bounded, explainable search
- Treat modal containment and keyboard traps as different claims
A checkout dialog opens, and pressing Tab never reaches the Pay button. The focus ring keeps cycling through two quantity controls, but a mouse user can finish the order. A test that clicks Pay will stay green while the keyboard path is completely blocked.
The repair begins with evidence from actual sequential navigation. Press the keys a keyboard user presses, assert the element the browser focuses, and put a deliberate bound around any search for an exit. That makes Playwright focus order testing useful without turning the DOM into a brittle list of every link.
Follow browser focus instead of guessing from the DOM
Focus order is behavior, not a sorted selector query. The browser considers the document, element semantics, disabled and hidden state, tabindex, and the current focus position when it performs sequential navigation. Application code can also respond to keydown and move focus elsewhere. Reading every element with [tabindex] cannot reproduce all of that.
The DOM still provides important evidence. Native links with href, buttons, form controls, and other interactive elements participate according to platform rules. tabindex="0" places a custom focusable element in the ordinary sequence, while a negative value generally removes it from sequential keyboard navigation but still permits programmatic focus. Positive tabindex values create a separate ordering that is hard to maintain and can pull focus away from the visual and reading sequence.
CSS can make the mismatch worse. Grid and flexbox can place a later DOM node visually before an earlier one. The screen looks correct to a mouse user, but Tab continues through the browser's focus sequence. WCAG's focus-order criterion is concerned with preserving meaning and operability, not with making every page follow one universal left-to-right list. The expected sequence in a test should express the task and the interface's intended meaning.
Playwright provides two useful layers. page.keyboard.press('Tab') sends the key through the page from the current focused element. expect(locator).toBeFocused() repeatedly checks whether the locator points to the focused DOM node until it passes or reaches the expectation timeout. Together they test movement and outcome. The action alone only proves that the key was sent.
locator.focus() and locator.press('Tab') serve different purposes. focus() calls focus on the matching element. A locator-level press() first focuses its target and then presses the key. Either can create a controlled starting point, but both can bypass the broken route by which a user was supposed to arrive. Use page.keyboard once the initial focus state is established if sequential movement is the claim.
The active element is observable from page JavaScript through document.activeElement. That is useful for diagnostics, although it has boundaries. From a parent document, focus inside an iframe is represented by the iframe element. With shadow DOM, the document may expose the host while a deeper shadow root exposes its own active element. Role locators and toBeFocused() are clearer for ordinary assertions; a recursive active-element helper is valuable when a component crosses a shadow boundary.
Do not define correctness as "the first element in source order receives focus." A page may intentionally place a skip link first. A destructive control may belong after the primary action even if layout puts both in one row. A composite widget may use one Tab stop and arrow keys for internal movement. Write down the interaction model before encoding the order.
Prove a short path that matters to the user
Start with one task, one entry point, and a handful of meaningful stops. A sign-in page might promise that a user entering from the top reaches the skip link, email field, password field, submit button, and recovery link in that order. That sequence is stable because each stop changes what the user can do.
The initial focus assertion matters. If a new autofocus behavior lands, it changes the starting condition and should not be silently absorbed. After a normal navigation, many pages have the body as the active element unless the application moves focus. The following test treats that as an explicit product precondition, then uses only keyboard movement.
import { expect, test } from '@playwright/test';
test('sign-in controls follow the task order', async ({ page }) => {
await page.goto('/sign-in');
await expect(page.locator('body')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Skip to sign in' })).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Email address')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Password')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Sign in' })).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Forgot password?' })).toBeFocused();
});This test can fail for several useful reasons. Removing href from the recovery link can take it out of ordinary keyboard navigation. Adding a positive tabindex to a marketing link can pull that link ahead of the form. An autofocus script can make the body precondition fail. Reordering the DOM without considering focus can change the observed path. Each change affects a user, not merely the test fixture.
The test also has a maintenance cost. Adding a legitimate "show password" button creates a new focus stop. The expected path must then change. That is not necessarily flake or brittleness; it is review pressure on a meaningful interaction change. Keep the sequence in one readable test so the product decision is obvious in review.
Do not duplicate this exact path in Chromium, Firefox, WebKit, desktop, mobile emulation, and every locale without a reason. Native controls and responsive navigation can create project-specific behavior, but each matrix dimension multiplies execution. Put the core task in representative projects, then add a particular combination when browser or layout risk warrants it.
A composite widget needs a different oracle. For a tab list, WCAG-compatible implementations commonly use Tab to enter the widget and arrow keys to move among tabs. A test that expects every tab to receive a separate Tab stop may reject the intended keyboard model. Assert entry, arrow-key movement, selection behavior, and exit according to the component contract instead of flattening the widget into page order.
Shift+Tab deserves targeted coverage at boundaries. A forward path can work while the reverse handler mistakenly jumps over a control. Test the highest-risk transition, such as returning from Submit to Password, rather than mirroring the entire page sequence. A short reverse assertion catches custom key handling without doubling every test.
Detect a trap with a bounded, explainable search
An unbounded loop is a test bug. If a component traps focus, a loop that presses Tab until it reaches the next section can run until the test timeout and provide little evidence. Use a bound derived from the component's known number of stops, record every observed stop, and fail with that sequence.
Consider a product carousel with Previous, Pause, and Next controls followed by a "View all offers" link. The test below starts by focusing Previous directly, which costs no key press, so only the two controls after it and the exit link cost one press each. Three presses is therefore the exact budget, and deriving it in code from the count of controls after entry keeps the arithmetic visible. If a custom handler cycles back to Previous, the observed sequence exposes the trap.
import { expect, test, type Page } from '@playwright/test';
async function focusedLabel(page: Page): Promise<string> {
return page.evaluate(() => {
const element = document.activeElement as HTMLElement | null;
if (!element) return '<none>';
return (
element.getAttribute('aria-label') ||
element.textContent?.replace(/\s+/g, ' ').trim() ||
element.id ||
element.tagName.toLowerCase()
);
});
}
test('keyboard focus can leave the offers carousel', async ({ page }) => {
await page.goto('/offers');
const previous = page.getByRole('button', { name: 'Previous offer' });
const exit = page.getByRole('link', { name: 'View all offers' });
await previous.focus();
// previous.focus() lands on the first control without a Tab, so only the
// controls after it cost a press, and the exit link costs one more.
const controlsAfterEntry = 2; // Pause offers, Next offer
const maxPresses = controlsAfterEntry + 1;
const observed: string[] = [];
for (let press = 0; press < maxPresses; press += 1) {
await page.keyboard.press('Tab');
observed.push(await focusedLabel(page));
if (await exit.evaluate((element) => element === document.activeElement)) break;
}
expect(observed, `Observed focus sequence: ${observed.join(' -> ')}`).toContain(
'View all offers',
);
await expect(exit).toBeFocused();
});The bound is not a magic accessibility number, and it is one press smaller than it looks. Entering with previous.focus() consumes the first control for free, so a healthy run reaches the exit on press three: Pause offers, Next offer, View all offers. Writing the bound as controlsAfterEntry + 1 keeps that reasoning in the file instead of in someone's head.
Get it wrong by one and the test stops being a test. Allow four presses and adding a Mute button still lands the exit link on the last permitted press, so the loop breaks, the assertion passes, and the contract change slips through unreviewed. Allow three and the same addition runs the budget out on Next offer, the final toBeFocused() fails, and the attached sequence names the control that was added. Check this the way you would check any gate: add a control to the fixture and confirm the test goes red. If it stays green, the bound is decoration. A generic limit of 50 fails the same way from the other direction, escaping in time to miss an accidental 20-stop cycle that is functionally unusable.
The diagnostic label helper favors an accessible label, then text, ID, and tag name. It may produce the same label for multiple controls. That is acceptable for a human-readable failure attachment but not for an identity assertion. The final toBeFocused() on the exit link is the decisive oracle.
If the component uses shadow DOM, document.activeElement may give the shadow host instead of the internal button. Record an active path that descends through open shadow roots. Closed shadow roots intentionally hide internals, so test the host's public behavior and exposed accessibility contract rather than reaching through implementation boundaries.
An iframe has another boundary. From the outer document, the iframe element can be active while its own document focuses an internal control. Use a frame locator to assert the internal target. A repeated outer label of iframe is not sufficient evidence of a trap; it may represent valid movement inside the embedded application.
Distinguish a trap from a long sequence. If the observed labels are all different and eventually approach the expected exit, the component may simply contain more controls than the test assumed. If the same short cycle repeats and the exit never appears, a trapping handler or incorrect tabindex is more likely. Attach the sequence so triage does not depend on watching a video frame by frame.
Treat modal containment and keyboard traps as different claims
A modal dialog often contains focus while it is open. Pressing Tab on the last control may move to the first, and Shift+Tab on the first may move to the last. That containment supports the modal interaction because background controls should not become the active task. It is not automatically a failure under the no-keyboard-trap criterion if the user can leave using a keyboard method and understands that method.
Test three contracts independently: initial focus enters an appropriate control, navigation stays within the open dialog, and a supported keyboard exit closes it. Many regressions affect only one. A dialog can contain focus correctly but ignore Escape. It can close on Escape but leave focus on the body, forcing the user to rediscover their place. Focus restoration is often an application or dialog-pattern expectation even when the narrow WCAG criterion does not spell out that exact destination.
import { expect, test } from '@playwright/test';
test('address dialog contains focus and Escape returns to its opener', async ({ page }) => {
await page.goto('/checkout');
const opener = page.getByRole('button', { name: 'Change delivery address' });
await opener.click();
const dialog = page.getByRole('dialog', { name: 'Delivery address' });
const street = dialog.getByLabel('Street address');
const cancel = dialog.getByRole('button', { name: 'Cancel' });
await expect(street).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(cancel).toBeFocused();
await page.keyboard.press('Tab');
await expect(street).toBeFocused();
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(opener).toBeFocused();
});The exact first and last controls are product decisions in this example. Your dialog may begin on a heading with tabindex="-1", a safe action, or the first invalid field. Do not copy the Street assertion without checking the intended interaction. The useful pattern is the separation of entry, containment, exit, and restoration.
The Shift+Tab check catches a common one-sided implementation. Teams often handle forward Tab at the last control but forget reverse navigation at the first. A mouse-based test never sees it. A test that only presses Escape also misses it.
Not every popup should contain focus. A nonmodal popover may allow Tab to continue into the document. A tooltip should not take focus at all. A menu, listbox, grid, and tab list each have their own keyboard model. Reusing a modal helper across all floating components can create false failures and can encourage incorrect product behavior.
There is also a near-miss involving the browser's native focus behavior. A test may report that focus left a dialog because a background control received focus, but the real cause is that the dialog was removed early by an application state change. Check whether the dialog is still attached and visible at the failing key action. Fixing a focus-loop helper will not repair premature dismissal.
Another look-alike is focus that moves correctly but has no visible indicator. toBeFocused() passes because the DOM active element is right. A user may still be unable to see where they are. Treat focus visibility as a separate visual or accessibility requirement, using a focused screenshot or a style-level assertion chosen for the design system. Do not claim a focus-order test proves focus appearance.
Read the trace and event sequence before rewriting selectors
A failed toBeFocused() tells you that the expected locator did not become the focused node within its timeout. The reason may be an extra valid stop, a skipped element, a key handler that called preventDefault(), an element removed during navigation, or a starting point different from the one the test assumed. The locator is only one part of the evidence.
Record a trace for the first failing attempt. In Trace Viewer, select the keyboard action. The Call panel identifies the key, and the action chronology shows what happened before and after it. Inspect the DOM snapshot for tabindex, disabled state, hidden ancestors, and whether the expected node exists. Check the Console panel for an exception from custom keyboard handling.
A compact focus-event recorder can fill the gap between actions. Install it before the interaction and attach its output after the test. The following code records genuine focusin events without moving focus itself. Its assertion fails if keyboard navigation never reaches the required checkout action.
import { expect, test } from '@playwright/test';
test('records the checkout focus path', async ({ page }, testInfo) => {
await page.goto('/checkout');
await page.evaluate(() => {
const log: string[] = [];
(window as Window & { __focusLog?: string[] }).__focusLog = log;
document.addEventListener('focusin', (event) => {
const element = event.target as HTMLElement;
log.push(
element.getAttribute('data-testid') ||
element.getAttribute('aria-label') ||
element.id ||
element.tagName.toLowerCase(),
);
});
});
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
const focusLog = await page.evaluate(
() => (window as Window & { __focusLog?: string[] }).__focusLog ?? [],
);
await testInfo.attach('focus-sequence.json', {
body: Buffer.from(JSON.stringify(focusLog, null, 2)),
contentType: 'application/json',
});
expect(focusLog).toContain('place-order');
await expect(page.getByRole('button', { name: 'Place order' })).toBeFocused();
});Install diagnostics only on focused investigations or behind a fixture option. A document-level listener has a small runtime cost, and its output can contain labels or text that your artifact policy treats as sensitive. Prefer stable test IDs or accessible labels and avoid recording typed field values.
Check the starting state before blaming the third key press. A cookie banner, experiment flag, validation error summary, or autofocus hook can insert a stop ahead of the expected path. The trace's first DOM snapshot and project metadata can expose that difference. Align fixtures and feature flags, or make the alternate path an explicit scenario.
Responsive changes deserve the same treatment. A desktop header may expose five links, while a narrow viewport offers one menu button. If local and CI projects use different viewports, their correct focus sequences differ. Keep viewport in project configuration and name tests around the layout they cover.
Positive tabindex is a particularly useful signature. If focus jumps to a visually distant control before the expected first input, inspect computed DOM attributes rather than adding another Tab press to the test. Removing the positive value and arranging a meaningful DOM order usually produces a more maintainable interface. If a third-party widget requires it, isolate and document that exception.
A two-column address form provides a concrete version of that failure. The DOM contains all shipping fields followed by all billing fields, but CSS alternates them visually by row: Shipping name, Billing name, Shipping street, Billing street. A keyboard test follows the DOM and appears to jump vertically through one column before returning to the top of the other. Nothing is flaky, and every field can receive focus. The order still conflicts with the visual grouping and can make the task hard to understand. Capture a screenshot at one focus stop and inspect the surrounding DOM order. The durable fix is to align the document structure and responsive layout with the intended reading sequence, not to assign positive tabindex values to every field.
That fix carries layout work. A single DOM order must support both wide and narrow designs, and CSS may need new grouping containers. It is still cheaper than maintaining a hand-numbered keyboard order across fields that appear conditionally. If product design accepts two coherent sequences, write separate viewport scenarios and state the meaning each preserves. Do not let one expected array accidentally define all layouts.
Disabled state creates another near-miss. A native disabled control does not behave like an enabled Tab stop, so a sequence may legitimately move past it. If the test expected focus on a disabled Submit button, the oracle is wrong unless the product uses a different focusable pattern to explain why submission is unavailable. Inspect the actual disabled state and the accessible explanation. Adding programmatic focus to the test would prove only that script moved focus, not that a keyboard user can reach the control in ordinary navigation.
Single-page navigation can also produce a focus failure outside the Tab sequence. Activating a navigation link updates the main region, but focus remains on a link that the new view removed or falls back to the body. The next Tab then starts somewhere surprising. Reproduce the route change with Enter, assert the documented post-navigation focus target, and only then begin the destination page's order test. This separates focus management during navigation from order within the new view.
A focus ring that lags behind DOM focus looks similar in a video. The active element may already be Place order while a CSS transition leaves the previous outline visible for a frame. toBeFocused() correctly reports DOM focus, but it does not validate the visible indicator. Check the trace snapshot and computed styles before changing the sequence. If the visible indicator is the defect, move it to a focused visual test with controlled rendering rather than weakening the order assertion.
When focus appears stuck on an input, inspect its key handlers. Rich editors, spreadsheets, and code editors may use Tab as an internal command. That behavior can be legitimate only when users have a documented keyboard path out. A generic form field that cancels Tab is a different defect. The event log looks similar, but the component's interaction model and exit instructions separate the cases.
Roll out coverage where it protects meaning
Begin with critical journeys and known problem components: authentication, checkout, dialogs, global navigation, editors, and embedded widgets. Avoid capturing every focusable element on every page into a golden array. Such snapshots fail whenever a harmless link appears and encourage reviewers to approve updates without considering meaning.
Extract helpers for observation, not for policy. A helper can press Tab, return a readable active-element label, or attach a sequence. The expected order should remain near the scenario because it belongs to that user task. A universal assertTabOrder(page, selectors) helper often hides why a stop matters.
Keep evidence from the original attempt while stabilizing the suite. With retries disabled, trace: 'retain-on-failure' records failed cases and removes successful traces. If your wider policy uses retries, decide whether you need the first attempt or the retry trace. A green retry must not erase the fact that focus navigation failed once.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: 0,
use: {
baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'keyboard-chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'keyboard-firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'keyboard-webkit', use: { ...devices['Desktop Safari'] } },
],
});Parallel execution is safe only when tests own their state. Focus itself is isolated by each Playwright page and browser context, but shared accounts, feature flags, or server-side carts can still collide. Give each checkout case separate data. Do not serialize the entire suite to hide shared-state failures.
The three-browser matrix costs roughly three project executions, plus trace storage for failures. That cost is justified for shared interaction primitives with browser-specific risk. For a large application, run a focused keyboard suite across the matrix and keep broader page-specific paths in one representative project on pull requests. A scheduled full run can cover lower-risk combinations.
Do not raise the global assertion timeout because one editor performs slow initialization. A missing focus move will then take longer to report everywhere. Give the editor a readiness assertion before starting keyboard input, or use a local timeout with a documented reason. Readiness should observe a real state, such as the editor's toolbar becoming enabled, not a sleep.
Know when exact focus order is the wrong test
A news feed can insert a relevant story or sponsored link without breaking the reader's task. If several focus sequences preserve meaning, an exact golden array turns harmless content changes into failures. Test that the user can reach and operate the important control instead. Do not use page-level Tab checks to verify every internal arrow-key transition in a composite widget. Cover that widget at component level and keep one integrated entry and exit path.
Programmatic focus tests also have a place. They are faster when the requirement is simply that opening an error summary moves focus to its heading. They do not replace Tab navigation when reachability is the concern. Name the test according to the mechanism so reviewers know which claim is being made.
The final trade-off is maintenance. Meaningful focus tests need updates when interaction design changes, and that review effort is part of their value. Keep the paths short, attach the observed sequence, and reject helpers that wander until something passes. A good failure should tell the engineer whether focus skipped a required stop, entered a cycle, stayed correctly contained in a modal, or never started from the assumed element.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
How do I test tab order with Playwright?
Begin from a known focus state, press `Tab` through `page.keyboard`, and assert each meaningful stop with `toBeFocused()`. Keep the expected sequence short enough that every stop represents a user task rather than a snapshot of the whole page.
Why not call focus on every element in a keyboard navigation test?
Calling `locator.focus()` proves that script can focus the element, but it bypasses sequential keyboard navigation. Use it to establish a test precondition, not to prove that Tab can reach a control.
Is focus containment inside a modal a keyboard trap?
Not automatically. A modal may intentionally cycle focus among its controls as long as the user has a keyboard method to dismiss or leave it and the method is understandable; test that exit path and focus restoration as separate contracts.
How many Tab presses should a keyboard trap test allow?
Derive the bound from the component's known interactive controls plus the expected exit, rather than choosing a universal number. The failure report should include the observed sequence so a newly added legitimate control is easy to distinguish from a cycle.
What evidence helps debug a focus-order failure in CI?
Inspect the trace around each keyboard action, the focused node in the DOM snapshot, and any focus event log attached by the test. Also compare the CI viewport and enabled feature flags, because responsive navigation can create a different sequence.
RELATED GUIDES
Continue the learning route
GUIDE 01
Test Reduced Motion with Playwright
Use Playwright reduced motion testing with media emulation to verify static alternatives, disabled animations, usable content, and regression checks in CI.
GUIDE 02
Test localStorage Directly with the Playwright API
Learn Playwright localStorage API testing through practical setup, failure analysis, CI evidence, security boundaries, and measurable release gates for QA and SDET teams.
GUIDE 03
Test sessionStorage Directly with the Playwright API
A practical guide to Playwright sessionStorage API testing, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 04
Test WebAuthn Passkey Registration with Playwright
Master Playwright WebAuthn passkey registration testing with implementation examples, failure analysis, evidence design, CI controls, and release-ready QA checklists.
GUIDE 05
Test WebSocket Subprotocol Negotiation with Playwright
Learn Playwright WebSocket subprotocol testing with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.