Keyboard Navigation and Accessibility Testing in Playwright
Most teams ship UIs that work flawlessly with a mouse and silently break the moment a user reaches for the Tab key. Broken focus order, invisible focus rings, and modals that trap nobody are exactly the defects automated suites miss and real users hit first. In this guide you will learn Playwright keyboard navigation accessibility testing end to end: driving the keyboard, asserting focus, verifying ARIA roles and the accessibility tree, and wiring in axe-core so a11y regressions fail your CI instead of your customers.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why Keyboard and Accessibility Testing Belongs in Playwright
Keyboard operability is the backbone of accessibility. If every interactive control can be reached and activated with Tab, Shift+Tab, Enter, Space, and arrow keys, you have already satisfied a large slice of WCAG 2.2 Level A and AA. Screen readers, switch devices, and power users all ride on the same keyboard plumbing. Playwright is unusually well suited to this because it drives a real browser, exposes a true keyboard device, and ships first-class role and accessible-name locators that mirror how assistive technology perceives the page.
The three pillars we will cover are: simulating keyboard input precisely, asserting that focus lands where it should, and validating the semantic accessibility tree, including an automated axe-core scan. Together these turn a vague “is it accessible?” question into deterministic pass/fail checks.
Driving the Keyboard with page.keyboard and locator.press
Playwright gives you two layers of keyboard control. page.keyboard dispatches keys at the page level (whatever currently has focus receives them), while locator.press() focuses a specific element first and then sends the key. For navigation flows you usually start with locator.focus() or a click, then walk the page with page.keyboard.press('Tab').
import { test, expect } from '@playwright/test';
test('user can reach the submit button using only the keyboard', async ({ page }) => {
await page.goto('https://example.com/signup');
// Start focus on the first field, then Tab through the form.
await page.getByLabel('Email').focus();
await page.keyboard.press('Tab'); // -> Password
await page.keyboard.press('Tab'); // -> Remember me checkbox
await page.keyboard.press('Space'); // toggle the checkbox
await page.keyboard.press('Tab'); // -> Submit button
// The button should now hold focus and respond to Enter.
const submit = page.getByRole('button', { name: 'Create account' });
await expect(submit).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByText('Welcome aboard')).toBeVisible();
});
A few real APIs are worth knowing. page.keyboard.press('Shift+Tab') moves focus backward. page.keyboard.down('Shift') and page.keyboard.up('Shift') let you hold a modifier across several presses, which is useful for range selection. For typing visible text use locator.pressSequentially() (the modern replacement for the old type() method) when you specifically need per-character keydown/keyup events; otherwise prefer locator.fill().
test('arrow keys navigate a custom listbox', async ({ page }) => {
await page.goto('https://example.com/menu');
const trigger = page.getByRole('button', { name: 'Choose plan' });
await trigger.focus();
await page.keyboard.press('Enter'); // open the listbox
const listbox = page.getByRole('listbox');
await expect(listbox).toBeVisible();
// Move down two options with the keyboard, then commit.
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await expect(trigger).toHaveText('Pro plan');
});
Asserting Focus Order and the Focus State
Reaching an element is not enough; focus has to be visible and logical. Playwright’s expect(locator).toBeFocused() is the cleanest way to assert which element currently owns focus. To capture the live focus target without knowing which locator it is, use the :focus CSS pseudo-class via page.locator(':focus') and read its accessible name or attributes.
test('tab order matches the visual reading order', async ({ page }) => {
await page.goto('https://example.com/profile');
const expectedOrder = ['First name', 'Last name', 'Country', 'Save'];
await page.getByLabel('First name').focus();
for (const label of expectedOrder) {
// Whatever currently has focus should have the expected accessible name.
const focused = page.locator(':focus');
await expect(focused).toHaveAccessibleName(label);
await page.keyboard.press('Tab');
}
});
Two assertions do a lot of heavy lifting here. toHaveAccessibleName() checks the computed accessible name (label, aria-label, or associated <label>) rather than brittle text content, and toHaveRole() verifies the semantic role. You can also detect “keyboard traps” — places where focus cannot escape — by Tabbing a bounded number of times and asserting focus eventually leaves a container.
Verifying a Visible Focus Indicator
WCAG 2.4.7 requires a visible focus indicator. Pure CSS outlines are hard to assert directly, but you can confirm the focused element renders a non-zero outline or box-shadow by evaluating its computed style. This catches the common bug where a designer set outline: none without a replacement.
test('focused control has a visible focus indicator', async ({ page }) => {
await page.goto('https://example.com/signup');
const submit = page.getByRole('button', { name: 'Create account' });
await submit.focus();
const hasIndicator = await submit.evaluate((el) => {
const s = getComputedStyle(el);
const outline = parseFloat(s.outlineWidth) > 0 && s.outlineStyle !== 'none';
const shadow = s.boxShadow !== 'none' && s.boxShadow !== '';
return outline || shadow;
});
expect(hasIndicator).toBe(true);
});
Reading the Accessibility Tree and ARIA Snapshots
Beyond focus, accessibility testing means verifying that the page exposes correct roles, names, and structure to assistive technology. Playwright’s role-based locators (getByRole) query the same accessibility tree a screen reader walks, so a passing getByRole('navigation') is meaningful evidence, not a CSS coincidence. For broader structural checks, expect(locator).toMatchAriaSnapshot() captures the accessible structure as readable YAML and fails on regressions.
test('landmark structure is exposed correctly', async ({ page }) => {
await page.goto('https://example.com/');
// Role + name queries mirror what a screen reader announces.
await expect(page.getByRole('navigation', { name: 'Primary' })).toBeVisible();
await expect(page.getByRole('main')).toBeVisible();
// Snapshot the link structure of the header (banner) region.
await expect(page.getByRole('banner')).toMatchAriaSnapshot(`
- banner:
- link "Home"
- navigation "Primary":
- link "Products"
- link "Pricing"
- link "Docs"
`);
});
The ARIA snapshot is the accessibility equivalent of a visual snapshot. When a developer accidentally removes a heading level or strips an aria-label, the YAML stops matching and the test fails with a precise diff. You can generate the initial snapshot by running the test once and copying the suggested expected value, or from the Playwright trace viewer’s Aria snapshot tab.
Automating WCAG Scans with axe-core and Playwright
Manual role assertions catch what you remember to check; @axe-core/playwright catches the rest. It runs the same axe engine the popular browser extension uses, evaluating dozens of WCAG rules against the live DOM. Install it with npm i -D @axe-core/playwright, then build an AxeBuilder bound to your page.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('home page has no critical accessibility violations', async ({ page }) => {
await page.goto('https://example.com/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
// Fail the test and print every violation with its impacted nodes.
expect(results.violations).toEqual([]);
});
In a real codebase you will inherit legacy issues you cannot fix today. Instead of disabling the whole check, scope it. AxeBuilder supports .include() and .exclude() to target a region, and .disableRules() to silence a specific known violation while you triage it. Pair this with expect.soft so a single failing scan does not stop the rest of your assertions.
test('checkout region is accessible, excluding the legacy widget', async ({ page }) => {
await page.goto('https://example.com/checkout');
const results = await new AxeBuilder({ page })
.include('#checkout-form')
.exclude('.legacy-promo-banner')
.disableRules(['color-contrast']) // tracked separately, fix pending
.analyze();
// Soft assertion: report violations but let later checks still run.
expect.soft(
results.violations,
JSON.stringify(results.violations.map((v) => v.id)),
).toEqual([]);
// Still verify the keyboard path independently.
await page.getByLabel('Card number').focus();
await page.keyboard.press('Tab');
await expect(page.locator(':focus')).toHaveAccessibleName('Expiry date');
});
A common pattern is to centralize this in a fixture so every test gets a scan for free without duplicating the builder. You can extend the base test with test.extend and expose a makeAxeBuilder helper that already applies your standard tags and global excludes.
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Testing Modals, Focus Traps, and Reduced Motion
Dialogs are where keyboard accessibility most often breaks. A correct modal moves focus into itself on open, traps Tab within it, restores focus to the trigger on close, and closes on Escape. All four are testable.
test('modal manages focus and closes on Escape', async ({ page }) => {
await page.goto('https://example.com/settings');
const trigger = page.getByRole('button', { name: 'Delete account' });
await trigger.focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
await expect(dialog).toBeVisible();
// Focus should have moved inside the dialog.
const insideDialog = await dialog.locator(':focus').count();
expect(insideDialog).toBe(1);
// Escape closes and focus returns to the original trigger.
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});
Accessibility also covers users who request reduced motion. Playwright can emulate that media preference with page.emulateMedia({ reducedMotion: 'reduce' }), letting you assert that animations are suppressed and that focus moves do not trigger motion-heavy transitions. The same method emulates forcedColors and colorScheme for high-contrast and dark-mode checks.
test('respects prefers-reduced-motion', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('https://example.com/');
const animatedDuration = await page
.getByTestId('hero-carousel')
.evaluate((el) => getComputedStyle(el).animationDuration);
// With reduced motion the carousel animation should be effectively off.
expect(['0s', '0.01s']).toContain(animatedDuration);
});
Quick Reference: Keys, Locators, and Assertions
| Goal | Real Playwright API | Notes |
|---|---|---|
| Move focus forward | page.keyboard.press('Tab') | Sends to whatever has focus |
| Move focus backward | page.keyboard.press('Shift+Tab') | Reverse tab order |
| Activate control | page.keyboard.press('Enter') / 'Space' | Space toggles checkboxes/buttons |
| Focus a specific element | locator.focus() or locator.press(key) | Focuses before sending key |
| Assert focused element | expect(locator).toBeFocused() | Cleanest focus check |
| Read live focus target | page.locator(':focus') | Pair with accessible-name asserts |
| Assert role | expect(locator).toHaveRole('button') | Semantic, not tag-based |
| Assert accessible name | expect(locator).toHaveAccessibleName('Save') | Computed name per ARIA spec |
| Snapshot a11y structure | expect(locator).toMatchAriaSnapshot(...) | YAML structural diff |
| Full WCAG scan | new AxeBuilder({ page }).analyze() | From @axe-core/playwright |
| Emulate reduced motion | page.emulateMedia({ reducedMotion: 'reduce' }) | Also forcedColors, colorScheme |
Putting It Together in CI
Keyboard and a11y tests are deterministic, fast, and run headless, so they belong in your normal Playwright project rather than a separate nightly job. A practical strategy is one axe scan per key page, plus targeted keyboard-flow tests for every interactive component (forms, menus, dialogs, data grids). Use expect.soft in scan tests so you collect all violations in one run, attach the JSON results with testInfo.attach() for triage, and gate the merge on zero new violations. Mastering Playwright keyboard navigation accessibility testing this way means inaccessible UI fails the build the same way a broken API call would — which is exactly where you want that feedback.
FAQ
Use page.keyboard.press('Tab') to move focus to the next focusable element and page.keyboard.press('Shift+Tab') to move backward. Set an initial focus with locator.focus() or a click, then assert the result with expect(locator).toBeFocused() or by reading page.locator(':focus') and checking its accessible name. This lets you verify the entire tab order matches the visual reading order.
Does Playwright have built-in accessibility testing?
Playwright ships role-based locators (getByRole, getByLabel), accessibility assertions (toHaveRole, toHaveAccessibleName, toHaveAccessibleDescription), and ARIA snapshots (toMatchAriaSnapshot) that query the real accessibility tree. For full WCAG rule coverage you add the official @axe-core/playwright package and run new AxeBuilder({ page }).analyze(). The older page.accessibility.snapshot() API still exists but is deprecated in favor of ARIA snapshots.
How do I test keyboard focus traps in modals?
Open the dialog via the keyboard, then assert focus moved inside it by checking dialog.locator(':focus').count() equals one. Press Tab repeatedly to confirm focus cycles within the dialog rather than escaping to the page behind it. Finally press Escape and assert the dialog is hidden and focus returned to the trigger with expect(trigger).toBeFocused() — that round trip proves the focus trap and restoration work correctly.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
