Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 091 / 100

Testing keyboard navigation: what developers should know

Make sure every flow works without a mouse.

Expona Admin9 min read

Testing keyboard navigation: what developers should know

Keyboard navigation is often overlooked in end-to-end testing, yet it's one of the most critical accessibility patterns your application needs to support. A user without a mouse, someone using a screen reader, or a power user trying to work faster all depend on your keyboard flows working flawlessly. In this guide, we'll walk through how to test keyboard navigation with Playwright, catch common focus management issues, and ensure every interaction in your application works without touching the mouse.

Why keyboard navigation matters in production

Before diving into code, let's acknowledge why this matters. Keyboard navigation isn't just an accessibility concern—it directly affects user retention and operational efficiency. Teams building internal tools, financial dashboards, and data-heavy applications see significant productivity gains when keyboard flows work well. Users with motor impairments, people working in high-speed environments, and developers using your product all rely on it. From a business perspective, keyboard accessibility reduces support tickets and expands your addressable user base.

Playwright makes testing keyboard navigation straightforward. The page.keyboard API gives us programmatic control over key presses, and assertions like toBeFocused() let us verify focus state at every step. Let's explore how to build confidence in these flows.

Using page.keyboard.press('Tab') to navigate

The Tab key is the foundation of keyboard navigation. It moves focus forward through interactive elements in DOM order. To test this with Playwright, we use page.keyboard.press('Tab') and then assert which element receives focus.

Consider a real-world example: a form with username, password, and submit button. Here's how we'd test tabbing through it:

typescript
import { test, expect } from '@playwright/test';

test('navigate form with Tab key', async ({ page }) => {
  await page.goto('http://localhost:3000/login');
  
  // Initial focus should be on username input
  const usernameInput = page.locator('input[name="username"]');
  await usernameInput.focus();
  await expect(usernameInput).toBeFocused();
  
  // Tab to password field
  await page.keyboard.press('Tab');
  const passwordInput = page.locator('input[name="password"]');
  await expect(passwordInput).toBeFocused();
  
  // Tab to submit button
  await page.keyboard.press('Tab');
  const submitButton = page.locator('button[type="submit"]');
  await expect(submitButton).toBeFocused();
  
  // Tab again—focus should cycle or reach the end
  await page.keyboard.press('Tab');
  // Verify behavior based on your design (cycle to first element or stop)
});

In this test, we're verifying that Tab moves focus in the expected order. This catches bugs where form fields are reordered in CSS but not in the DOM, or where hidden elements steal focus. The order matters: screen reader users and keyboard-only users navigate in DOM order, not visual order.

For more complex interfaces, you might want to test Shift+Tab as well to move focus backward:

typescript
// Move focus backward
await page.keyboard.press('Shift+Tab');
await expect(passwordInput).toBeFocused();

// Continue backward
await page.keyboard.press('Shift+Tab');
await expect(usernameInput).toBeFocused();

This ensures your focus trap and navigation work in both directions, which is important for users who prefer to correct mistakes by tabbing backward.

Assert focus with toBeFocused()

The toBeFocused() matcher is your primary tool for verifying keyboard navigation. It confirms that an element currently has focus, which is essential after any keyboard interaction.

Here's a scenario from e-commerce: a product filter interface where users navigate through categories, price ranges, and sorting options using the keyboard. We want to verify each interactive element receives focus in sequence:

typescript
test('keyboard navigation through filter panel', async ({ page }) => {
  await page.goto('http://localhost:3000/products');
  
  const categoryFilter = page.locator('[data-testid="category-filter"]');
  const priceRangeSlider = page.locator('[data-testid="price-slider"]');
  const sortDropdown = page.locator('[data-testid="sort-dropdown"]');
  const applyButton = page.locator('button:has-text("Apply Filters")');
  
  // Start in the filter panel
  await categoryFilter.focus();
  await expect(categoryFilter).toBeFocused();
  
  // Navigate through all controls
  await page.keyboard.press('Tab');
  await expect(priceRangeSlider).toBeFocused();
  
  await page.keyboard.press('Tab');
  await expect(sortDropdown).toBeFocused();
  
  await page.keyboard.press('Tab');
  await expect(applyButton).toBeFocused();
});

Notice how we verify focus after each Tab press. This is intentional: it catches subtle issues where JavaScript handlers interfere with focus flow, or where CSS focus styles hide focus state changes.

A common gotcha is testing focus on elements that require interaction first. For example, a custom dropdown that only becomes focusable after being activated. Use click() or focus() to set up the state before testing Tab behavior:

typescript
test('keyboard navigation in custom dropdown', async ({ page }) => {
  await page.goto('http://localhost:3000/dashboard');
  
  const dropdown = page.locator('[role="combobox"]');
  
  // Open the dropdown first
  await dropdown.click();
  await expect(dropdown).toBeFocused();
  
  // Now Tab should move to the next element or into dropdown options
  // depending on your implementation
  await page.keyboard.press('Tab');
  const firstOption = page.locator('[role="option"]:first-child');
  await expect(firstOption).toBeFocused();
});

Test Escape to close dialogs

Pressing Escape should close modals, dialogs, and popups. This is a critical keyboard interaction that many applications get wrong or skip testing. The user expectation is universal: Escape always closes the current overlay.

Here's a test for a modal dialog in a SaaS application:

typescript
test('Escape key closes modal dialog', async ({ page }) => {
  await page.goto('http://localhost:3000/settings');
  
  const deleteAccountButton = page.locator('button:has-text("Delete Account")');
  const confirmDialog = page.locator('[role="dialog"]');
  
  // Open the modal
  await deleteAccountButton.click();
  await expect(confirmDialog).toBeVisible();
  
  // Focus should be inside the dialog
  const confirmButton = page.locator('[role="dialog"] button:has-text("Confirm")');
  await expect(confirmButton).toBeFocused();
  
  // Press Escape
  await page.keyboard.press('Escape');
  
  // Modal should close and be hidden
  await expect(confirmDialog).toBeHidden();
  
  // Focus should return to the button that opened it (optional but good practice)
  await expect(deleteAccountButton).toBeFocused();
});

This test verifies three things: the modal opens, Escape closes it, and ideally focus returns to the triggering element. This last part is called "focus restoration" and significantly improves keyboard navigation experience.

For nested dialogs or popovers, test that Escape only closes the topmost layer:

typescript
test('Escape closes only the active popover', async ({ page }) => {
  await page.goto('http://localhost:3000/editor');
  
  const messageButton = page.locator('button:has-text("More options")');
  const primaryPopover = page.locator('[role="dialog"][data-id="primary"]');
  const nestedMenu = page.locator('[role="menu"][data-id="nested"]');
  
  // Open primary popover
  await messageButton.click();
  await expect(primaryPopover).toBeVisible();
  
  // Open nested menu within popover
  const submenuTrigger = page.locator('[role="dialog"] button:has-text("Advanced")');
  await submenuTrigger.click();
  await expect(nestedMenu).toBeVisible();
  
  // First Escape closes the nested menu only
  await page.keyboard.press('Escape');
  await expect(nestedMenu).toBeHidden();
  await expect(primaryPopover).toBeVisible();
  
  // Second Escape closes the primary popover
  await page.keyboard.press('Escape');
  await expect(primaryPopover).toBeHidden();
});

This prevents users from accidentally closing the entire interface when they meant to close only the submenu.

Catch focus traps

A focus trap is when keyboard focus gets stuck and can't escape, typically in a modal where Tab cycles forever between elements inside the modal without ever reaching outside elements. While sometimes intentional (a true modal should trap focus), sometimes they're bugs that prevent users from reaching the rest of the page.

Here's how to test for unintended focus traps:

typescript
test('modal creates proper focus trap', async ({ page }) => {
  await page.goto('http://localhost:3000/checkout');
  
  const checkoutModal = page.locator('[role="dialog"]');
  const firstInput = page.locator('[role="dialog"] input:first-child');
  const lastButton = page.locator('[role="dialog"] button:last-child');
  
  // Open modal
  await page.locator('button:has-text("Begin Checkout")').click();
  
  // Simulate full Tab cycle inside the modal
  await firstInput.focus();
  
  // Tab through multiple times
  for (let i = 0; i < 10; i++) {
    await page.keyboard.press('Tab');
    
    // Verify we never leave the modal
    const activeFocusedElement = await page.evaluate(() => {
      const el = document.activeElement as HTMLElement;
      return el?.closest('[role="dialog"]') ? 'inside' : 'outside';
    });
    
    expect(activeFocusedElement).toBe('inside');
  }
  
  // Verify Escape can always exit the trap
  await page.keyboard.press('Escape');
  await expect(checkoutModal).toBeHidden();
});

This test confirms two things: Tab keeps focus inside the modal (intentional trap), and Escape provides an escape hatch. Without this, users on keyboards can get stuck.

In cases where focus shouldn't be trapped (a dropdown menu, a tooltip, a sidebar), test that Tab eventually escapes:

typescript
test('dropdown does not trap focus', async ({ page }) => {
  await page.goto('http://localhost:3000/editor');
  
  const dropdownTrigger = page.locator('[role="combobox"]');
  await dropdownTrigger.focus();
  
  // Tab through dropdown options
  for (let i = 0; i < 8; i++) {
    await page.keyboard.press('Tab');
  }
  
  // Eventually, focus should escape the dropdown and reach the next page element
  const focusedElement = await page.evaluate(() => document.activeElement?.tagName);
  expect(['INPUT', 'BUTTON', 'A']).toContain(focusedElement);
});

This prevents accidentally creating permanent focus traps in non-modal UI.

Common mistakes

  • Testing focus on non-focusable elements: Divs and spans aren't focusable by default. If you want them to be, add tabindex="0" or use semantic HTML. Your tests should reflect what users can actually reach.
  • Ignoring hidden elements: Elements with display: none or visibility: hidden shouldn't receive focus. If your test finds focus jumping to a hidden element, that's a bug in your JavaScript focus management.
  • Not testing with actual keyboard interaction: Some developers use element.focus() in tests and skip page.keyboard.press('Tab'). These behave differently. Use keyboard presses to mirror real user behavior.
  • Forgetting about focus restoration: When a modal or popover closes, focus should return to the element that opened it. This requires tracking focus state and restoring it in your close handler.
  • Testing focus without asserting visibility: Ensure elements with focus are also visible. A test that passes focus to a hidden element missed an important bug.
  • Assuming Tab order matches visual order: Always verify Tab order explicitly, especially in complex layouts with CSS Grid or Flexbox where visual and DOM order can diverge.

Key takeaways

  • Use page.keyboard.press('Tab') and page.keyboard.press('Shift+Tab') to test keyboard navigation forward and backward through your UI.
  • Assert focus state after each keyboard interaction using toBeFocused() to catch focus management bugs.
  • Verify that Escape closes modals, dialogs, and popovers, and ideally returns focus to the triggering element.
  • Test focus traps deliberately: modals should trap focus (and provide an Escape hatch), but regular UI shouldn't.
  • Remember that keyboard navigation is not a nice-to-have—it's a fundamental interaction pattern that affects accessibility, productivity, and user satisfaction.
  • Run keyboard navigation tests as part of your regular CI pipeline, not just accessibility audits.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles