Screen Reader and Keyboard Navigation Testing: A Practical Guide
Keyboard and screen reader testing is the part of accessibility work that most teams skip or do poorly. The reason is usually practical: it requires learning new tools, new interaction patterns, and it can't be fully automated with the same confidence as color contrast or missing labels.
But this is where the real user impact lives. A screen reader user who can't navigate your modal, can't operate your custom dropdown, or never hears that their form submission succeeded — that's a broken product for them, regardless of your axe score.
This guide is practical and specific: keyboard focus order, tab index management, focus trapping, playwright-screen-reader for automation, skip navigation links, custom keyboard interaction patterns, and announcement testing.
Keyboard Focus Order Testing
Focus order is determined by the DOM order, with the exception of elements that have positive tabindex values. The core rule: the sequence in which Tab moves through interactive elements must match the logical reading and interaction order.
What to Test
- Tab through the entire page — every interactive element should receive focus
- Focus indicator is always visible (never disappears)
- Tab order matches visual left-to-right, top-to-bottom order (unless a different order is documented and intentional)
- No interactive elements are unreachable by keyboard
- No non-interactive elements receive keyboard focus (unless they contain scrollable content)
Manual Testing Protocol
1. Open the page in a browser
2. Click once in the browser window to establish a document context
3. Press Tab repeatedly, tracing the order
4. Document: which element receives focus first, second, etc.
5. Verify every interactive element appears in the sequence
6. Verify focus moves in a logical direction
7. Press Shift+Tab to reverse — verify it moves backward consistentlyAutomated Focus Order Testing with Playwright
Playwright can trace the tab order programmatically:
// focus-order.spec.js
import { test, expect } from '@playwright/test';
test('tab order follows logical DOM order', async ({ page }) => {
await page.goto('/');
// Get all focusable elements in DOM order
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
const domOrderElements = await page.$$eval(focusableSelectors, elements =>
elements.map(el => ({
tag: el.tagName.toLowerCase(),
text: (el.textContent || el.getAttribute('aria-label') || el.getAttribute('placeholder') || '').trim().slice(0, 50),
tabindex: el.getAttribute('tabindex')
}))
);
// Check for positive tabindex — these break natural order
const positiveTabindex = domOrderElements.filter(el =>
el.tabindex !== null && parseInt(el.tabindex) > 0
);
if (positiveTabindex.length > 0) {
throw new Error(
`Found ${positiveTabindex.length} elements with positive tabindex (breaks natural focus order):\n` +
positiveTabindex.map(el => ` <${el.tag}> tabindex="${el.tabindex}" "${el.text}"`).join('\n')
);
}
});
test('all interactive elements are keyboard reachable', async ({ page }) => {
await page.goto('/');
// Tab through the page and collect focused elements
const focusedElements = [];
let previousElement = null;
const maxTabs = 100; // safety limit
for (let i = 0; i < maxTabs; i++) {
await page.keyboard.press('Tab');
const current = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
return {
tag: el.tagName.toLowerCase(),
text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 50),
id: el.id,
class: el.className.slice(0, 50)
};
});
if (!current) break;
// Detect when we've cycled back to the beginning
if (focusedElements.length > 0) {
const first = focusedElements[0];
if (current.id && current.id === first.id) break;
}
focusedElements.push(current);
}
console.log('Tab order:');
focusedElements.forEach((el, i) => {
console.log(` ${i + 1}. <${el.tag}> "${el.text || el.id || el.class}"`);
});
// Assert skip nav link appears first (or early)
const skipLinkIndex = focusedElements.findIndex(el =>
el.text.toLowerCase().includes('skip') || el.class.includes('skip')
);
expect(skipLinkIndex).toBeLessThanOrEqual(2); // First or second focusable element
});Tab Index Management
Tab index has three meaningful values:
| Value | Meaning |
|---|---|
| Omitted | Element follows its default focusability (interactive elements are focusable, non-interactive aren't) |
0 |
Makes a non-interactive element focusable in DOM order. Use sparingly. |
-1 |
Removes from tab order but keeps programmatically focusable. Used for focus trap management. |
> 0 |
Inserts the element at that position in the tab order, ignoring DOM order. Never use this. |
When to Use tabindex="0"
Only when you're creating a custom interactive widget that needs to be keyboard-focusable but uses an element that isn't naturally focusable. Before doing this, ask: why aren't you using a native element?
<!-- A custom widget that must be focusable -->
<div
role="slider"
tabindex="0"
aria-label="Volume"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="50"
>When to Use tabindex="-1"
For programmatic focus control:
// Focusing a non-interactive element to announce content to screen readers
function showErrorSummary(errors) {
const summary = document.getElementById('error-summary');
summary.innerHTML = errors.map(e => `<li>${e}</li>`).join('');
summary.tabIndex = -1; // Make it programmatically focusable
summary.focus(); // Move focus to announce the error
}
// Modal focus trap setup
function setupFocusTrap(container) {
const focusable = container.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
container.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) {
last.focus();
e.preventDefault();
}
} else {
if (document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
});
}Focus Trapping in Modals and Dialogs
A focus trap must:
- Move focus into the modal on open
- Prevent Tab from leaving the modal
- Prevent Shift+Tab from leaving the modal
- Move focus back to the trigger on close
Testing a focus trap in Playwright:
test('focus trap in modal', async ({ page }) => {
await page.goto('/');
await page.click('[data-testid="open-dialog"]');
await page.waitForSelector('[role="dialog"]', { state: 'visible' });
// Get all focusable elements inside the dialog
const focusableInDialog = await page.$$eval(
'[role="dialog"] button, [role="dialog"] input, [role="dialog"] a[href], [role="dialog"] [tabindex="0"]',
els => els.map(el => ({
tag: el.tagName.toLowerCase(),
text: (el.textContent || el.getAttribute('aria-label') || '').trim()
}))
);
const focusCount = focusableInDialog.length;
expect(focusCount).toBeGreaterThan(0);
// Tab through all elements plus one more — should wrap to first
for (let i = 0; i < focusCount; i++) {
await page.keyboard.press('Tab');
}
// After tabbing past the last element, focus should be on the first
const focusedText = await page.evaluate(() => {
const el = document.activeElement;
return (el?.textContent || el?.getAttribute('aria-label') || '').trim();
});
expect(focusedText).toBe(focusableInDialog[0].text);
});
test('focus returns to trigger after dialog closes', async ({ page }) => {
await page.goto('/');
const triggerText = await page.textContent('[data-testid="open-dialog"]');
await page.click('[data-testid="open-dialog"]');
await page.waitForSelector('[role="dialog"]');
// Close via Escape
await page.keyboard.press('Escape');
await page.waitForSelector('[role="dialog"]', { state: 'hidden' });
const focusedText = await page.evaluate(() =>
(document.activeElement?.textContent || '').trim()
);
expect(focusedText).toBe(triggerText?.trim());
});Screen Reader Testing with playwright-screen-reader
@guidepup/playwright provides programmatic screen reader control — launching NVDA on Windows or VoiceOver on macOS and reading what it speaks.
npm install --save-dev @guidepup/playwrightThis requires running on the appropriate OS and having the screen reader available. It's most practical in CI environments running Windows (for NVDA) or macOS runners.
// screen-reader.spec.js
import { voTest as test } from '@guidepup/playwright';
import { expect } from '@playwright/test';
test('announces page title on load', async ({ page, voiceOver }) => {
await page.goto('https://example.com');
await voiceOver.interact();
// Move to the first item
await voiceOver.next();
const spokenText = await voiceOver.lastSpokenPhrase();
expect(spokenText).toContain('Example Domain');
});
test('form labels are announced when fields receive focus', async ({
page,
voiceOver
}) => {
await page.goto('/contact');
await voiceOver.interact();
// Navigate to the email field
await page.keyboard.press('Tab');
const announcement = await voiceOver.lastSpokenPhrase();
expect(announcement).toContain('Email address');
expect(announcement).toContain('edit text'); // type of field
});
test('error messages are announced', async ({ page, voiceOver }) => {
await page.goto('/contact');
await voiceOver.interact();
// Submit the form without filling it in
await page.keyboard.press('Tab'); // focus the submit button
await page.keyboard.press('Space'); // activate it
// Wait for validation
await page.waitForTimeout(500);
const announcement = await voiceOver.lastSpokenPhrase();
// role="alert" should be announced immediately
expect(announcement.toLowerCase()).toContain('required');
});For teams that can't run real screen readers in CI, a lightweight alternative is testing that the markup is structured correctly for screen reader consumption:
// Test announcement structure without real SR
test('status updates are in a live region', async ({ page }) => {
await page.goto('/');
// Find all live regions
const liveRegions = await page.$$eval(
'[aria-live], [role="status"], [role="alert"], [role="log"]',
elements => elements.map(el => ({
tag: el.tagName,
liveValue: el.getAttribute('aria-live'),
role: el.getAttribute('role'),
atomic: el.getAttribute('aria-atomic')
}))
);
expect(liveRegions.length).toBeGreaterThan(0);
// Status messages should use polite
const statusRegion = liveRegions.find(r => r.role === 'status');
expect(statusRegion).toBeDefined();
// Error messages should use assertive
const alertRegion = liveRegions.find(r => r.role === 'alert');
expect(alertRegion).toBeDefined();
});Skip Navigation Links
Skip navigation links allow keyboard users to bypass repeated navigation blocks and jump directly to the main content. They appear at the top of the page and are typically visually hidden but visible when focused.
Testing skip links:
test('skip navigation link is present and functional', async ({ page }) => {
await page.goto('/');
// The skip link should be the first focusable element
await page.keyboard.press('Tab');
const focusedElement = await page.evaluate(() => {
const el = document.activeElement;
return {
text: el?.textContent?.trim(),
href: el?.getAttribute('href'),
visible: el ? window.getComputedStyle(el).display !== 'none' : false
};
});
// Skip link text should communicate its purpose
expect(focusedElement.text?.toLowerCase()).toMatch(/skip|jump|main content/);
expect(focusedElement.href).toBeTruthy();
expect(focusedElement.visible).toBe(true); // Must be visible when focused
});
test('skip link moves focus to main content', async ({ page }) => {
await page.goto('/');
// Press Tab to focus skip link
await page.keyboard.press('Tab');
// Activate the skip link
await page.keyboard.press('Enter');
// Focus should now be on or within the main content area
const activeElementRole = await page.evaluate(() => {
const el = document.activeElement;
return {
id: el?.id,
role: el?.getAttribute('role'),
tag: el?.tagName?.toLowerCase()
};
});
// Main content should receive focus — either <main>, role="main", or #main-content
const isMainContent =
activeElementRole.role === 'main' ||
activeElementRole.tag === 'main' ||
activeElementRole.id === 'main-content' ||
activeElementRole.id === 'main';
expect(isMainContent).toBe(true);
});Skip link implementation for reference:
<!-- At the very top of <body> -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav>...</nav>
<header>...</header>
<main id="main-content" tabindex="-1">
<!-- tabindex="-1" makes it programmatically focusable without being in tab order -->
...
</main>.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
z-index: 9999;
}
.skip-link:focus {
top: 0;
}Testing Custom Keyboard Interactions
Complex widgets — menus, trees, grids, carousels — require specific keyboard interactions defined in the ARIA Authoring Practices Guide. These patterns must be explicitly tested.
Dropdown Menu (ARIA Menu Pattern)
test('dropdown menu keyboard navigation', async ({ page }) => {
await page.goto('/');
const menuButton = page.getByRole('button', { name: 'File' });
await menuButton.focus();
// Enter or Space opens the menu
await page.keyboard.press('Enter');
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
// Arrow down should move focus to first menu item
const firstItem = page.getByRole('menuitem').first();
await expect(firstItem).toBeFocused();
// Arrow down moves to next item
await page.keyboard.press('ArrowDown');
const secondItem = page.getByRole('menuitem').nth(1);
await expect(secondItem).toBeFocused();
// Arrow up moves back
await page.keyboard.press('ArrowUp');
await expect(firstItem).toBeFocused();
// Home key moves to first item
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Home');
await expect(firstItem).toBeFocused();
// End key moves to last item
await page.keyboard.press('End');
const lastItem = page.getByRole('menuitem').last();
await expect(lastItem).toBeFocused();
// Escape closes and returns focus to trigger
await page.keyboard.press('Escape');
await expect(menu).not.toBeVisible();
await expect(menuButton).toBeFocused();
});Combobox / Autocomplete
test('combobox keyboard interactions', async ({ page }) => {
await page.goto('/search');
const input = page.getByRole('combobox', { name: 'Search' });
await input.focus();
// Type to trigger suggestions
await page.keyboard.type('java');
const listbox = page.getByRole('listbox');
await expect(listbox).toBeVisible();
// Check aria-expanded is true
await expect(input).toHaveAttribute('aria-expanded', 'true');
// Arrow down moves focus into listbox
await page.keyboard.press('ArrowDown');
const firstOption = page.getByRole('option').first();
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
// aria-activedescendant should be updated on the input
const activeDesc = await input.getAttribute('aria-activedescendant');
const firstOptionId = await firstOption.getAttribute('id');
expect(activeDesc).toBe(firstOptionId);
// Enter selects the option
await page.keyboard.press('Enter');
await expect(listbox).not.toBeVisible();
await expect(input).toHaveAttribute('aria-expanded', 'false');
});Tree View
test('tree view keyboard navigation', async ({ page }) => {
await page.goto('/file-browser');
const tree = page.getByRole('tree');
const firstNode = tree.getByRole('treeitem').first();
await firstNode.focus();
// Arrow right expands a collapsed node
await page.keyboard.press('ArrowRight');
await expect(firstNode).toHaveAttribute('aria-expanded', 'true');
// Arrow right again moves focus to first child
await page.keyboard.press('ArrowRight');
const childNode = tree.getByRole('treeitem').nth(1);
await expect(childNode).toBeFocused();
// Arrow left moves to parent
await page.keyboard.press('ArrowLeft');
await expect(firstNode).toBeFocused();
// Arrow left collapses an expanded node
await page.keyboard.press('ArrowLeft');
await expect(firstNode).toHaveAttribute('aria-expanded', 'false');
});Testing Announcements Systematically
Beyond focus management, screen reader announcements come from:
- Focus — when an element receives focus, its name, role, and state are announced
- Live regions —
aria-live="polite",aria-live="assertive",role="status",role="alert" - Descriptions —
aria-describedbycontent is announced after the label and role - State changes —
aria-expanded,aria-pressed,aria-checked,aria-selected
Test each category:
test('icon buttons announce their label', async ({ page }) => {
await page.goto('/');
// Find all buttons that contain only SVG/icon content
const iconButtons = await page.$$eval(
'button:not(:has(> *:not(svg)):not(:has(> img)))',
buttons => buttons.map(btn => ({
hasAriaLabel: !!btn.getAttribute('aria-label'),
hasAriaLabelledby: !!btn.getAttribute('aria-labelledby'),
hasTitle: !!btn.querySelector('title'),
innerText: btn.innerText.trim()
}))
);
iconButtons.forEach(btn => {
const hasAccessibleName = btn.hasAriaLabel || btn.hasAriaLabelledby ||
btn.hasTitle || btn.innerText;
expect(hasAccessibleName).toBe(true);
});
});
test('form field descriptions are programmatically associated', async ({ page }) => {
await page.goto('/register');
const passwordInput = page.getByLabel('Password');
const describedBy = await passwordInput.getAttribute('aria-describedby');
expect(describedBy).toBeTruthy();
// The description element must exist
const descElement = page.locator(`#${describedBy}`);
await expect(descElement).toBeAttached();
const descText = await descElement.textContent();
expect(descText?.length).toBeGreaterThan(10); // Should have meaningful content
});
test('loading states are communicated to screen readers', async ({ page }) => {
await page.goto('/dashboard');
// Trigger a data load
await page.click('[data-testid="refresh"]');
// Check for aria-busy during loading
const busyElements = await page.$$('[aria-busy="true"]');
expect(busyElements.length).toBeGreaterThan(0);
// Wait for load to complete
await page.waitForSelector('[aria-busy="false"]', { timeout: 5000 });
// Verify status region was updated
const statusText = await page.textContent('[role="status"]');
expect(statusText).toBeTruthy();
});Building a Keyboard Testing Checklist
A repeatable checklist for every feature that ships:
KEYBOARD NAVIGATION
□ All interactive elements reachable by Tab
□ Tab order matches logical reading order
□ No positive tabindex values
□ Shift+Tab works in reverse
FOCUS VISIBILITY
□ Focus indicator visible on all interactive elements
□ Focus indicator meets 3:1 contrast ratio
□ Focus indicator not hidden by overflow:hidden or z-index
MODALS AND OVERLAYS
□ Focus moves into modal on open
□ Focus trapped inside modal while open
□ Escape key closes modal
□ Focus returns to trigger on close
SKIP NAVIGATION
□ Skip link is first focusable element
□ Skip link visible when focused
□ Skip link activates and moves focus to main content
CUSTOM WIDGETS
□ Menu: Arrow keys navigate items, Enter activates, Escape closes
□ Tabs: Arrow keys switch tabs, content panel updated
□ Accordion: Enter/Space toggles, aria-expanded reflects state
□ Tree: Arrow keys expand/collapse and navigate
□ Grid: Arrow keys navigate cells
ANNOUNCEMENTS
□ Status messages in role="status" or aria-live="polite"
□ Error messages in role="alert" or aria-live="assertive"
□ State changes (aria-expanded, aria-pressed, aria-selected) kept current
□ Loading states communicated via aria-busy or live regionsThe single most effective change you can make to your testing process: add a keyboard-only walkthrough as a required step in QA sign-off. Not a separate accessibility audit — part of normal functional verification. If the feature works by keyboard, you've already covered a large fraction of the WCAG criteria that matter most to users.