QA How-To
Test Keyboard Focus Order with Playwright Step by Step
Learn how to run a Playwright test keyboard focus order step by step, assert each Tab stop, handle reverse navigation, and diagnose failures reliably.
18 min read | 2,736 words
TL;DR
Open the page, focus a known starting element, press Tab with page.keyboard.press('Tab'), and assert the newly focused locator with expect(locator).toBeFocused(). Repeat for the critical journey, then run the same path backward with Shift+Tab.
Key Takeaways
- Start from a known focus state before pressing Tab.
- Assert focus with expect(locator).toBeFocused() after every keyboard action.
- Use role and label locators so tests describe the accessible experience.
- Test Shift+Tab as well as forward Tab navigation.
- Model repeated focus checks with a small helper that still produces readable failures.
- Treat positive tabindex values and unexpected focus traps as defects to investigate.
- Combine deterministic focus-order tests with broader accessibility scans.
A Playwright test keyboard focus order step by step should reproduce what a keyboard user does: enter the page at a known point, press Tab once, and verify the element that receives focus. Repeat that action for the complete task, then check reverse movement with Shift+Tab.
This tutorial builds a runnable TypeScript test for a checkout form, including a skip link, form controls, a custom help button, and the final submit button. If you are designing a broader accessibility suite, read the Playwright accessibility automation complete guide for the surrounding strategy.
The goal is not to encode every focusable node on every page. It is to protect the meaningful order of a critical workflow and produce a failure that tells you exactly where keyboard navigation changed.
TL;DR
await page.getByRole('link', { name: 'Skip to checkout' }).focus();
await expect(page.getByRole('link', { name: 'Skip to checkout' })).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Email address')).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(page.getByRole('link', { name: 'Skip to checkout' })).toBeFocused();
Use toBeFocused() instead of reading CSS classes or taking screenshots. The assertion checks the browser's actual active element and waits for the expected state.
| Approach | Best use | Main limitation |
|---|---|---|
locator.focus() |
Establish a deterministic starting point | Does not simulate how the user reached it |
page.keyboard.press('Tab') |
Exercise native forward navigation | Requires a known start state |
page.keyboard.press('Shift+Tab') |
Exercise reverse navigation | Platform overlays can interfere outside isolated tests |
expect(locator).toBeFocused() |
Assert the intended focus target | Does not by itself explain why another node won focus |
page.evaluate(() => document.activeElement) |
Add diagnostic details | Easy to overuse and couple to markup |
What You Will Build
By the end, you will have:
- A minimal accessible checkout fixture served by Playwright.
- A forward Tab-order test using user-facing locators.
- A reverse Shift+Tab test.
- A reusable helper for longer focus sequences.
- Diagnostics for identifying the unexpected active element.
- A CI-ready test command with a focused project configuration.
The finished test protects this sequence: skip link, email, country, help, terms, and purchase. It also proves that backward navigation returns through the same controls in reverse order.
Prerequisites
Use Node.js 20 or newer and a current stable release of @playwright/test. The Playwright APIs in this tutorial are stable and current in 2026. Start in an empty directory or add the files to an existing Playwright project.
npm init playwright@latest
Choose TypeScript, accept the tests directory, and install Chromium when prompted. If the project already exists, update the runner and browser explicitly:
npm install --save-dev @playwright/test@latest
npx playwright install chromium
Confirm the installation:
npx playwright --version
You should see a version rather than a module error. You also need permission to create a small HTML fixture under tests/fixtures. No axe package is required for focus-order assertions.
Step 1: Set Up the Playwright Test Keyboard Focus Order Step by Step Project
Create playwright.config.ts with one desktop Chromium project. A single browser keeps this tutorial quick, while your production suite can add Firefox and WebKit after the behavior is stable.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
},
webServer: {
command: 'npx vite tests/fixtures --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
Install Vite as the fixture server:
npm install --save-dev vite
The webServer process gives the test a real HTTP origin. That is closer to production behavior than opening a local file, and it makes navigation consistent locally and in CI. trace: 'on-first-retry' preserves useful evidence without tracing every successful run.
Verify the step: run npx playwright test --list. Playwright should print the configured Chromium project without reporting a configuration or missing-package error. The list can be empty because you have not created the test yet.
Step 2: Create an Accessible Focus-Order Fixture
Create tests/fixtures/index.html. The fixture intentionally uses native controls and DOM order. It includes one custom button to demonstrate that custom styling does not require custom keyboard behavior.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Keyboard checkout fixture</title>
<style>
.skip-link { position: absolute; left: 1rem; top: -4rem; }
.skip-link:focus { top: 1rem; }
main { max-width: 36rem; margin: 5rem auto; }
form { display: grid; gap: 1rem; }
:focus-visible { outline: 3px solid #6d28d9; outline-offset: 3px; }
</style>
</head>
<body>
<a class="skip-link" href="#checkout">Skip to checkout</a>
<header>Demo Store</header>
<main id="checkout" tabindex="-1">
<h1>Checkout</h1>
<form>
<label>
Email address
<input name="email" type="email" autocomplete="email" />
</label>
<label>
Country
<select name="country">
<option>India</option>
<option>United States</option>
</select>
</label>
<button type="button" aria-describedby="help-text">Payment help</button>
<p id="help-text">Opens payment guidance.</p>
<label>
<input name="terms" type="checkbox" />
Accept terms
</label>
<button type="submit">Purchase</button>
</form>
</main>
</body>
</html>
Native links, inputs, selects, and buttons enter the sequential focus order automatically. tabindex="-1" lets the skip link move focus to main when activated, but it does not insert main into ordinary Tab navigation. Avoid positive tabindex values such as tabindex="2"; they create a separate priority order that is hard to maintain.
Verify the step: run npx vite tests/fixtures --host 127.0.0.1 --port 4173, open the printed URL, click the page background, and press Tab. A visible outline should move through the skip link and controls in DOM order. Stop the server afterward because Playwright will start it for the test.
Step 3: Assert the Starting Focus State
Create tests/keyboard-focus-order.spec.ts. First, navigate and place focus on the skip link. This removes uncertainty about browser chrome, page loading, and whichever element a previous interaction selected.
import { expect, test } from '@playwright/test';
test.describe('checkout keyboard focus order', () => {
test('starts from the skip link', async ({ page }) => {
await page.goto('/');
const skipLink = page.getByRole('link', { name: 'Skip to checkout' });
await skipLink.focus();
await expect(skipLink).toBeFocused();
});
});
Calling focus() here is setup, not the behavior under test. The rest of the sequence uses real keyboard input. A role locator also checks an important assumption: the start node is exposed as a link with the expected accessible name.
Do not begin by pressing Tab on body and assuming the first focus target will always be identical. Browser settings, injected development tools, and new page controls can change that entry point. Establish the starting node explicitly when the test's purpose is relative order inside a workflow.
Verify the step: run npx playwright test tests/keyboard-focus-order.spec.ts. The output should show one passed test. If it fails at getByRole, inspect the fixture URL and accessible name before adding more navigation.
Step 4: Test Forward Tab Order with Focus Assertions
Expand the file with the complete forward journey. Each Tab press has exactly one corresponding assertion, so a failure identifies the first incorrect transition.
import { expect, test, type Locator, type Page } from '@playwright/test';
async function pressTabAndExpect(page: Page, target: Locator) {
await page.keyboard.press('Tab');
await expect(target).toBeFocused();
}
test.describe('checkout keyboard focus order', () => {
test('moves forward through the checkout in logical order', async ({ page }) => {
await page.goto('/');
const skipLink = page.getByRole('link', { name: 'Skip to checkout' });
const email = page.getByLabel('Email address');
const country = page.getByLabel('Country');
const help = page.getByRole('button', { name: 'Payment help' });
const terms = page.getByLabel('Accept terms');
const purchase = page.getByRole('button', { name: 'Purchase' });
await skipLink.focus();
await expect(skipLink).toBeFocused();
await pressTabAndExpect(page, email);
await pressTabAndExpect(page, country);
await pressTabAndExpect(page, help);
await pressTabAndExpect(page, terms);
await pressTabAndExpect(page, purchase);
});
});
page.keyboard.press('Tab') sends a keyboard event through the browser. toBeFocused() checks whether the locator resolves to the active element and auto-retries until the assertion timeout. The helper is deliberately small. It removes repetition without hiding which target Playwright expected.
Prefer accessible locators over CSS selectors such as #email. When a label is removed or a button name becomes unclear, the test should fail because the keyboard experience has also become harder to understand. For focused coverage of naming problems, follow the tutorial for detecting missing accessible names with Playwright.
Verify the step: run npx playwright test tests/keyboard-focus-order.spec.ts --project=chromium. Expect one passed test. Temporarily move the help button after Purchase in the HTML and rerun. The test should fail when it expects help, proving it detects a real order regression. Restore the fixture after this check.
Step 5: Test Reverse Order with Shift+Tab
Forward movement is only half of keyboard navigation. Users often move backward after skipping a field or noticing an error. Add a second test that starts at Purchase and walks backward.
test('moves backward through the checkout in reverse order', async ({ page }) => {
await page.goto('/');
const email = page.getByLabel('Email address');
const country = page.getByLabel('Country');
const help = page.getByRole('button', { name: 'Payment help' });
const terms = page.getByLabel('Accept terms');
const purchase = page.getByRole('button', { name: 'Purchase' });
await purchase.focus();
await expect(purchase).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(terms).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(help).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(country).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(email).toBeFocused();
});
Use the portable Playwright key chord Shift+Tab. Do not emulate reverse movement by calling focus() on each previous element. That would only verify that the elements can receive programmatic focus, not that the browser places them in the expected sequential order.
A forward test can pass while reverse navigation fails if application code intercepts keydown, a focus trap has a broken previous-node calculation, or a control is removed between interactions. Keep both directions for dialogs, menus, forms, and other critical paths.
Verify the step: run the spec again. Expect two passed tests. Change the terms checkbox to tabindex="-1" and rerun. Both tests should fail around that control. Remove the temporary attribute when verification is complete.
Step 6: Verify Skip-Link Activation and Programmatic Focus
Tab order and focus management meet at skip links. A skip link should be the first useful page control, and activating it should move focus to its target. Add this behavior test without folding it into the long form sequence.
test('moves focus to checkout when the skip link is activated', async ({ page }) => {
await page.goto('/');
const skipLink = page.getByRole('link', { name: 'Skip to checkout' });
const checkout = page.locator('#checkout');
await skipLink.focus();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/#checkout$/);
await expect(checkout).toBeFocused();
});
The main target has tabindex="-1", which makes it programmatically focusable without adding another Tab stop. Browsers normally focus a fragment target during same-page navigation when it is focusable. The URL assertion distinguishes navigation from an unrelated script that merely moved focus.
For custom widgets or client-side routing, you may need application code that calls target.focus() after rendering. Test the outcome, not the implementation. A keyboard user needs focus on the new content and an understandable accessible name or heading.
Verify the step: run npx playwright test -g "skip link". Expect the URL to end in #checkout and the test to pass. Remove tabindex="-1" from main to confirm that the focus assertion catches a target that cannot reliably receive focus, then restore it.
Step 7: Add Diagnostics for Unexpected Focus
A plain toBeFocused() failure names the expected locator, but a long page may still leave you asking what received focus instead. Add a diagnostic helper that reports the active element's tag, id, role, accessible label attributes, and short text. Use it only when a sequence fails.
import type { TestInfo } from '@playwright/test';
async function attachActiveElement(page: Page, testInfo: TestInfo) {
const activeElement = await page.evaluate(() => {
const element = document.activeElement;
if (!(element instanceof HTMLElement)) return null;
return {
tag: element.tagName.toLowerCase(),
id: element.id || null,
role: element.getAttribute('role'),
ariaLabel: element.getAttribute('aria-label'),
name: element.getAttribute('name'),
text: element.innerText.trim().slice(0, 120),
};
});
await testInfo.attach('active-element.json', {
body: JSON.stringify(activeElement, null, 2),
contentType: 'application/json',
});
}
Call it from a guarded assertion when you need richer evidence:
try {
await expect(help).toBeFocused();
} catch (error) {
await attachActiveElement(page, test.info());
throw error;
}
Keep the normal assertion. Comparing document.activeElement manually with an id often creates weaker checks and poor waiting behavior. The evaluation is diagnostic evidence attached to the Playwright report, while toBeFocused() remains the verdict.
A trace can also show the DOM snapshot around the failure. Screenshots help confirm whether a focus indicator is visible, but screenshots alone cannot reliably establish which DOM node owns focus.
Verify the step: deliberately expect help immediately after focusing the email field. Run with npx playwright test --reporter=html, then open the report with npx playwright show-report. The failed test should contain active-element.json showing the email input. Restore the correct assertion.
Step 8: Make the Focus Test CI Ready
Run the focused spec in CI before expanding it across every page. The following GitHub Actions job uses the project lockfile, installs the required browser dependencies, and uploads the report after a failure.
name: Keyboard accessibility
on:
pull_request:
workflow_dispatch:
jobs:
focus-order:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test tests/keyboard-focus-order.spec.ts --project=chromium
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 7
Commit package-lock.json so CI installs the reviewed dependency versions. Pinning the workflow actions by major tag is concise, but teams with strict supply-chain rules can pin full commit SHAs. Keep the artifact retention appropriate for your repository policy.
Do not add arbitrary sleeps to stabilize focus assertions. If an element appears after an asynchronous transition, wait for a user-visible readiness condition, then press Tab. Playwright assertions already retry, while fixed delays make failures slower and still race on busy runners.
Verify the step: run the exact CI test command locally. Push the workflow on a branch and confirm the job reports three passing tests. Open the uploaded HTML report once, even on a successful trial, to confirm the artifact path is correct.
Troubleshooting
Problem: The first Tab focuses the browser UI instead of the page -> Browser chrome is outside Playwright's page automation model, but an unknown page start can still produce confusing results. Focus a known in-page locator during setup, assert it, and then begin the sequence.
Problem: toBeFocused() expects the right control but focus stays on body -> Check whether the control is disabled, hidden, detached, or covered by a rerender. Inspect document.activeElement through the diagnostic attachment and use a trace to see whether the expected node was replaced.
Problem: A custom control is skipped -> Use a native button, a, input, or other appropriate semantic element. If a custom element is unavoidable, implement its complete keyboard behavior and semantics. Adding tabindex="0" alone does not provide button activation, a role, or a name.
Problem: The order differs from the visual layout -> Inspect DOM order and CSS layout. Flexbox order, grid placement, and absolute positioning can make visual order disagree with sequential focus order. Prefer changing the DOM so reading, visual, and focus order align.
Problem: The test passes alone but fails in the suite -> Look for shared state, persistent dialogs, service workers, and tests that reuse an authenticated page unexpectedly. Playwright test contexts are isolated by default, so avoid global pages and close overlays through real setup or fixture logic.
Problem: Tab gets trapped inside a dialog -> That can be correct while the modal is open. Assert that focus wraps within the dialog, Escape closes it when supported, and focus returns to the control that opened it. If the dialog is visually closed but the trap remains active, treat that as an application defect.
Best Practices
- Test a meaningful journey, not a brittle inventory of every link in a dynamic header.
- Establish the initial focus state explicitly and assert it before keyboard input.
- Use
getByRole()andgetByLabel()so failures reflect accessibility semantics. - Assert after every Tab or Shift+Tab, not only at the final destination.
- Keep controls in logical DOM order and avoid positive
tabindexvalues. - Test focus restoration after dialogs, drawers, and route changes.
- Keep focus-order tests separate from visual focus-indicator checks. Both matter, but they diagnose different defects.
- Run critical sequences in more than one browser after the core test is stable.
A broad automated scan complements these targeted checks but cannot infer every product-specific workflow. Use Playwright axe scans for specific components to catch rule-based violations, then retain explicit keyboard tests for behavior and order.
Where To Go Next
Place this focused journey inside the larger approach described in the complete Playwright accessibility automation guide. Prioritize checkout, authentication, search, navigation, dialogs, and any workflow that must be completed without a pointer.
Next, add complementary coverage:
- Follow the missing accessible names Playwright tutorial so every focus target is understandable.
- Test asynchronous feedback with the Playwright live region announcement validation guide.
- Apply component-level axe scan examples to catch detectable semantic and structural violations.
Finally, perform manual keyboard testing. Automation protects known sequences, while a human can judge whether the full experience is efficient, visible, understandable, and free from surprising traps.
Interview Questions and Answers
Q: How do you test keyboard focus order in Playwright?
Start from a known focused locator, send Tab with page.keyboard.press('Tab'), and assert the next locator with expect(locator).toBeFocused(). Repeat after each transition so the first incorrect focus movement produces the failure. Add Shift+Tab coverage for important workflows.
Q: Why use toBeFocused() instead of document.activeElement?
toBeFocused() is a web-first Playwright assertion with retry behavior and locator-aware failure output. document.activeElement is valuable for diagnostics, but manual comparisons can produce brittle selectors and do not automatically wait like an assertion.
Q: Should a focus-order test start by pressing Tab on the page body?
Not usually when testing an internal workflow. Explicitly focus and assert a known starting element so the test measures relative navigation rather than browser entry behavior. Create a separate test if the first focusable element on page entry is itself a requirement.
Q: Why should positive tabindex values be avoided?
Positive values create a priority sequence ahead of ordinary focusable content. The resulting order becomes difficult to understand and maintain as controls change. Logical DOM order with native elements and occasional tabindex="0" or -1 is more predictable.
Q: Can an axe scan prove that keyboard focus order is correct?
No. Automated rules can flag some tabindex and structural issues, but they do not understand every intended task sequence. Combine rule-based scans with explicit Tab assertions and manual keyboard exploration.
Q: How do you debug the wrong element receiving focus?
Capture a trace and attach a small serialization of document.activeElement. Inspect DOM order, hidden overlays, keydown handlers, rerenders, and active focus traps. Keep toBeFocused() as the assertion so the diagnostic code does not replace the actual test.
Common Mistakes
The most common mistake is calling focus() on every target. That proves programmatic focusability, not Tab order. Use focus() only to establish the start, then drive movement with keyboard input.
Another mistake is asserting a focus CSS class. An application can render that class while a different node owns focus, or the browser can show :focus-visible without any application class. Assert browser focus and test indicator styling separately.
Avoid one giant loop containing anonymous CSS selectors. A compact helper is useful, but keep user-facing locator names visible in the test and assert each transition. When the journey changes, the failure should tell a reviewer whether the changed order is intended.
Do not assume a passing Chromium test replaces manual or cross-browser testing. Native control behavior, application event handlers, and platform conventions can expose differences. Use Chromium for fast pull-request feedback, then schedule broader browser coverage based on risk.
Conclusion
To test keyboard focus order with Playwright, establish a known starting element, press Tab one time, and assert the intended target with toBeFocused(). Repeat through the critical journey, verify Shift+Tab in reverse, and capture active-element details when a transition fails.
Keep the sequence grounded in semantic locators and logical DOM order. Once this focused test is reliable, add accessible-name checks, live-region validation, component scans, cross-browser runs, and manual keyboard review for complete coverage.
Interview Questions and Answers
How would you automate keyboard focus order with Playwright?
I would navigate to the page, focus and assert a known starting control, then send one Tab key at a time with `page.keyboard.press('Tab')`. After each key, I would use `expect(locator).toBeFocused()` with role or label locators. I would also cover Shift+Tab and focus restoration for high-risk widgets such as dialogs.
Why is locator.focus() insufficient for testing Tab order?
`locator.focus()` directly assigns focus and bypasses the browser's sequential navigation algorithm. It proves that a node can receive programmatic focus, not that a keyboard user reaches it in the intended position. I use it only to establish a deterministic test start.
How do you make a focus-order failure easy to diagnose?
I assert after every keyboard transition so the first wrong step fails. I enable a Playwright trace on retry and attach a short serialization of `document.activeElement`, including tag, id, role, and accessible label attributes. I keep the web-first focus assertion as the verdict.
What is the difference between tabindex 0, -1, and a positive tabindex?
`tabindex="0"` places an otherwise focusable custom node in normal sequential order. `tabindex="-1"` permits programmatic focus but excludes the node from ordinary Tab navigation. Positive values create a priority order and should generally be avoided because they make navigation fragile.
Would you use CSS selectors or accessible locators for a keyboard test?
I prefer `getByRole()` and `getByLabel()` because they describe what the keyboard user encounters. They also expose missing names or incorrect semantics while testing behavior. I use a CSS locator only for a target such as a focusable main region that lacks a more suitable user-facing locator.
How do automated scans and focus-order tests complement each other?
Rule-based scans catch detectable issues such as certain invalid tabindex patterns and missing semantics. Focus-order tests validate an application-specific sequence and keyboard behavior that a generic rule cannot infer. Manual testing remains necessary to judge the overall efficiency, visibility, and logic of the experience.
Frequently Asked Questions
How do I test Tab order in Playwright?
Focus a known starting locator, call `page.keyboard.press('Tab')`, and assert the expected next locator with `await expect(locator).toBeFocused()`. Repeat one action and one assertion at a time through the critical workflow.
How do I press Shift+Tab in Playwright?
Use `await page.keyboard.press('Shift+Tab')`. Start from a known focused element and assert each previous focus target to verify reverse navigation.
What does Playwright's toBeFocused assertion check?
`toBeFocused()` checks that the locator resolves to the document's currently focused element. It is a web-first assertion, so Playwright retries it until it passes or reaches the assertion timeout.
Can Playwright detect every keyboard accessibility issue?
No. Playwright can automate known keyboard actions and outcomes, but it cannot judge every aspect of usability or discover every intended journey. Combine focused automation with rule-based scans and manual keyboard testing.
Should I use tabindex to fix an incorrect focus order?
Prefer correcting DOM order and using native elements. Positive tabindex values create a separate priority order that becomes fragile, while `tabindex="-1"` is useful for programmatic targets that should not become ordinary Tab stops.
Why does my Playwright focus-order test fail intermittently?
Common causes include rerendered controls, animations, overlays, persistent focus traps, and starting from an unknown element. Wait for a user-visible ready state, establish the starting focus explicitly, and use a trace plus active-element diagnostics instead of fixed sleeps.
Related Guides
- Inspect WebSocket Frames with Playwright Step by Step
- Azure DevOps test pipelines: Step by Step (2026)
- CircleCI for test automation: Step by Step (2026)
- Flaky test quarantine in CI: Step by Step (2026)
- GitLab CI for test automation: Step by Step (2026)
- Grafana dashboards for test metrics: Step by Step (2026)