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.

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.
  • 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();
  }
});

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();
});

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.

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.


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.

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.