testing and automating accessibility

Keyboard Navigation Tests in Playwright

A keyboard navigation test proves a single, non-negotiable property: every interactive element in your UI can be reached, operated, and exited using only the keyboard. This guide shows how to write those tests in Playwright—pressing Tab and asserting each landing element in order, activating controls with Enter and Space, closing overlays with Escape, and confirming there is no keyboard trap. It is the practical companion to the broader End-to-End Accessibility Testing with Playwright workflow, and it pairs naturally with asserting focus order in Playwright, which locks down the full tab sequence.

WCAG Success Criteria Addressed:

  • 2.1.1 Keyboard
  • 2.1.2 No Keyboard Trap
  • 2.4.3 Focus Order
  • 2.4.7 Focus Visible

Why Keyboard Tests Matter:

  • Synthetic clicks pass even when a control is unreachable without a mouse.
  • Keyboard operability is the baseline for screen-reader, switch, and motor-impaired users.
  • Focus order bugs are invisible in static markup checks and only surface during real traversal.

Prerequisites

Before writing keyboard tests, confirm a few things are in place so the suite is deterministic and meaningful.

Implementation Guidelines:

  • Install @playwright/test and, for full-page scans alongside keyboard flows, @axe-core/playwright (the same integration used throughout automated accessibility testing with axe-core).
  • Establish a deterministic starting point for focus. Tab order depends on DOM order, so begin every flow from a fixed anchor (a skip link, the document body, or an explicitly focused element).
  • Use role-based locators (getByRole) so assertions describe user-perceivable intent rather than brittle CSS selectors.
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 the tab sequence is deterministic.
  await page.evaluate(() => document.body.focus());
});

Starting from a known state matters because page.keyboard.press('Tab') advances from wherever focus currently sits. A test that assumes focus begins at the body will drift if a prior step left focus elsewhere.


Pressing Tab and Asserting Focus Order

The core keyboard test presses Tab repeatedly and asserts that each expected element receives focus in the correct sequence. This validates both 2.1.1 Keyboard (everything is reachable) and 2.4.3 Focus Order (the sequence is logical).

Implementation Guidelines:

  • Assert after every Tab press, not just at the end—an early misorder cascades silently otherwise.
  • Use toBeFocused(), which auto-waits and reads the live document.activeElement.
  • Keep the expected sequence in source order so the test doubles as living documentation of the intended tab path.
test('header controls receive focus in DOM order', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('link', { name: 'Skip to main content' }).focus();

  await page.keyboard.press('Tab');
  // A11y rationale: 2.4.3 Focus Order — the logo link is the first stop.
  await expect(page.getByRole('link', { name: 'Acme Home' })).toBeFocused();

  await page.keyboard.press('Tab');
  await expect(page.getByRole('link', { name: 'Products' })).toBeFocused();

  await page.keyboard.press('Tab');
  await expect(page.getByRole('link', { name: 'Pricing' })).toBeFocused();

  await page.keyboard.press('Tab');
  // A11y rationale: 2.1.1 Keyboard — the search field must be reachable too.
  await expect(page.getByRole('searchbox', { name: 'Search' })).toBeFocused();
});

For longer sequences, a data-driven loop keeps the test readable while still asserting each step. Capturing and comparing the full sequence is covered in depth in asserting focus order in Playwright.

test('toolbar tabs through every control in order', async ({ page }) => {
  await page.goto('/editor');
  await page.getByRole('button', { name: 'Bold' }).focus();

  const expectedOrder = ['Bold', 'Italic', 'Underline', 'Insert link'];

  for (let i = 1; i < expectedOrder.length; i++) {
    await page.keyboard.press('Tab');
    await expect(page.getByRole('button', { name: expectedOrder[i] })).toBeFocused();
  }
});

Which key activates which control Three activation rules worth asserting. A button activates on both Enter and Space, and a test that only presses Enter will not catch a div pretending to be a button. A link activates on Enter only. A checkbox toggles on Space. Testing the key a control should ignore is as informative as testing the one it should honour. A button Enter and Space — testing only Enter misses a div in disguise A link Enter only; Space scrolls the page A checkbox Space toggles; Enter usually submits the surrounding form

Reaching a control is only half the requirement—it must also activate from the keyboard. Buttons respond to both Enter and Space; links respond to Enter. Testing activation proves the control is wired to keyboard events, not just onClick from a pointer.

Implementation Guidelines:

  • Focus the control first, then press the activation key. Do not use locator.click(), which bypasses the keyboard entirely.
  • Assert the observable result of activation (a navigation, an opened panel, a state change).
  • Test Space on buttons specifically, since native buttons must respond to it.
test('button activates with both Enter and Space', async ({ page }) => {
  await page.goto('/settings');
  const toggle = page.getByRole('button', { name: 'Enable notifications' });

  await toggle.focus();
  await page.keyboard.press('Enter');
  // A11y rationale: 2.1.1 Keyboard — Enter must activate a button control.
  await expect(toggle).toHaveAttribute('aria-pressed', 'true');

  await page.keyboard.press('Space');
  await expect(toggle).toHaveAttribute('aria-pressed', 'false');
});

test('nav link activates with Enter', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('link', { name: 'Pricing' }).focus();
  await page.keyboard.press('Enter');
  await expect(page).toHaveURL(/\/pricing/);
});

A skip link lets keyboard users jump past repetitive navigation straight to the main content. It is often the very first focusable element and is easy to break, so it deserves an explicit test.

Implementation Guidelines:

  • Tab once from the top of the document to reveal and focus the skip link.
  • Activate it with Enter and assert focus lands on the main landmark or its heading.
  • Confirm the target is focusable (it needs tabindex="-1" if it is not natively focusable).
test('skip link moves focus to main content', async ({ page }) => {
  await page.goto('/');
  await page.evaluate(() => document.body.focus());

  await page.keyboard.press('Tab');
  const skipLink = page.getByRole('link', { name: 'Skip to main content' });
  // A11y rationale: 2.4.1-adjacent — the skip link is the first stop for
  // keyboard users and must be reachable immediately.
  await expect(skipLink).toBeFocused();

  await page.keyboard.press('Enter');
  // After activation, focus should land on the main region so the next Tab
  // continues inside content, bypassing the nav.
  await expect(page.getByRole('main')).toBeFocused();
});

The keyboard journey worth locking down for any overlay Four assertions in sequence. Focus the trigger and confirm it is reachable by Tab. Activate it and confirm focus moves inside the overlay. Press Escape and confirm the overlay closes. Confirm focus is back on the trigger, which is the step that most often regresses and the one nothing else will catch. Tab reaches the trigger before anything else — an unreachable trigger fails immediately Activation moves focus inside assert the focused element is within the overlay Escape closes it from anywhere inside, not only from the close button Focus returns to the trigger the assertion most likely to catch a future regression

Opening and Escape-Closing a Modal

Overlays are where keyboard handling most often breaks. A correct modal moves focus inside on open, traps Tab within itself, closes on Escape, and returns focus to the trigger. Crucially, the focus trap must satisfy 2.1.2 No Keyboard Trap: the user can always leave via Escape.

The keyboard contract of an accessible modal A four-part loop. Focus starts on the trigger button. Pressing Enter opens the dialog and moves focus to its first field, satisfying 2.4.3 Focus Order. Tab then cycles within the dialog from Save back to the first field and never escapes to the page behind, keeping focus inside. Pressing Escape closes the dialog and returns focus to the original trigger, which together with the always-available exit satisfies 2.1.2 No Keyboard Trap. Trigger focused "Edit profile" button Dialog open focus moves to first field Enter opens dialog 2.4.3 Focus Order Tab cycles: Save → first field focus never escapes behind Escape closes dialog, focus returns to trigger 2.1.2 No Keyboard Trap

Implementation Guidelines:

  • Open with Enter from the focused trigger, then assert focus moved into the dialog.
  • Tab through the dialog and confirm focus cycles within it, never escaping to the page behind.
  • Press Escape and assert the dialog closes and focus returns to the original trigger.
test('modal opens, traps focus, and closes on Escape', 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();

  // A11y rationale: 2.4.3 Focus Order — focus enters the dialog on open.
  await expect(dialog.getByRole('textbox', { name: 'Display name' })).toBeFocused();

  // A11y rationale: 2.1.2 No Keyboard Trap — Tab cycles within the dialog,
  // and Escape always provides a way out.
  await page.keyboard.press('Tab');
  await expect(dialog.getByRole('button', { name: 'Save' })).toBeFocused();

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

  // Focus must return to the element that opened the dialog.
  await expect(trigger).toBeFocused();
});

The implementation patterns these tests validate—focus trapping, Escape handling, and trigger restoration—are detailed in Keyboard Navigation Patterns for Modals. The related contract for restoring focus after client-side navigation is covered in handling focus restoration after dynamic route changes.


How to Verify

Automated tests are necessary but not sufficient. Confirm each test reflects real behavior:

  • Manual keyboard pass. Put the mouse aside and reproduce each test path by hand. Press Tab, Shift+Tab, Enter, Space, and Escape. The order and outcomes should match your assertions exactly.
  • Visible focus check. Watch for a clearly visible focus indicator at every stop to satisfy 2.4.7 Focus Visible. A passing toBeFocused() assertion does not prove the ring is visible.
  • No-trap confirmation. Open every overlay and confirm Escape always exits and Tab never escapes behind it, satisfying 2.1.2 No Keyboard Trap.
  • Axe cross-check. Run @axe-core/playwright on the same pages to catch missing accessible names that would make your role-based locators ambiguous.
  • Regression failure test. Temporarily remove a control's keyboard handler or break the tab order and confirm the relevant test fails. A test that cannot fail is not protecting you.

Common a11y Mistakes

  • Activating with click() instead of a key press. locator.click() dispatches a pointer event, so a control wired only to mouse handlers passes the test while a keyboard user is blocked. Focus the element and press Enter or Space instead.
  • Asserting focus only at the end of a sequence. A single misordered stop cascades through every later assertion, hiding the real cause. Assert toBeFocused() after every Tab.
  • Starting a tab test from an unknown focus state. Without resetting focus to a fixed anchor in beforeEach, the first Tab advances from wherever the previous step left off and the sequence drifts.
  • Trusting toBeFocused() to prove a visible focus ring. It only reads document.activeElement; CSS may still be suppressing the outline, silently failing 2.4.7 Focus Visible. Verify the indicator visually or with a snapshot.
  • Skipping the trigger-restoration assertion on modals. A dialog that closes but drops focus to <body> strands the keyboard user. Always assert focus returns to the element that opened the overlay.
  • Testing Enter but not Space on buttons. Native <button> must respond to both; a role="button" shim that handles only Enter fails silently for Space unless you assert it.

Conclusion

Keyboard tests in Playwright turn "works without a mouse" from a hopeful assumption into an enforced contract. Start every flow from a deterministic focus anchor, press real keys rather than synthetic clicks, and assert after each step so a misorder surfaces at its source. Cover the four moments where keyboard support usually breaks—tab order, activation, the skip link, and modal open/trap/Escape/restore—and back the automated suite with a manual pass that confirms the focus ring is actually visible. Do that and 2.1.1, 2.1.2, 2.4.3, and 2.4.7 stop being aspirations and become regressions you catch in CI.


Frequently Asked Questions

Why shouldn't I just use locator.click() to activate controls?

click() dispatches a pointer event and bypasses the keyboard entirely. A button wired only to mouse events will pass a click-based test while failing 2.1.1 Keyboard. Focus the control and press Enter or Space so the test fails exactly when a keyboard user would be blocked.

How do I make my tab-order tests deterministic?

Always start from a fixed focus anchor—focus the body or a known element in beforeEach—because Tab advances from wherever focus currently is. Then assert after every press rather than only at the end, so a misorder is caught at its source instead of cascading.

How do I test that a modal isn't a keyboard trap?

Open the modal, Tab through it to confirm focus cycles within the dialog, then press Escape and assert the dialog hides and focus returns to the trigger. The Escape exit is what satisfies 2.1.2 No Keyboard Trap; a trap with no keyboard exit is a failure even if Tab cycling looks correct.

My toBeFocused() assertion passes but users report no focus ring. Why?

toBeFocused() only checks document.activeElement; it cannot see whether a focus indicator is visually rendered. Verify 2.4.7 Focus Visible manually or with a visual regression snapshot, since CSS may be suppressing the outline.