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:
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:
// 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:
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:
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:
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:
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:
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:
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: noneorvisibility: hiddenshouldn'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 skippage.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')andpage.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.






Comments
Loading comments…