Playwright Accessibility Auditing: Beyond axe-core Basics
Most teams integrate @axe-core/playwright and call it done. You get a scan, you fix the violations, you ship. But axe-core catches maybe 30–40% of real accessibility issues. The rest — focus management failures, broken ARIA live regions, keyboard traps, flawed reading order — require behavioral testing that axe can't see.
This post covers advanced Playwright patterns for the accessibility problems axe misses.
ARIA Live Region Testing
Live regions announce dynamic content to screen readers. Axe can verify the aria-live attribute exists, but it can't verify the region actually announces content at the right time with the right verbosity.
import { test, expect } from '@playwright/test';
test('live region announces form submission status', async ({ page }) => {
await page.goto('/checkout');
// Get the live region element
const statusRegion = page.locator('[aria-live="polite"][role="status"]');
await expect(statusRegion).toBeAttached();
// Submit the form
await page.fill('[name="email"]', 'user@example.com');
await page.click('[type="submit"]');
// Assert the live region receives content within 3 seconds
await expect(statusRegion).not.toBeEmpty({ timeout: 3000 });
await expect(statusRegion).toContainText(/submitting|processing/i);
// After completion, verify the announcement changes
await expect(statusRegion).toContainText(/success|submitted/i, { timeout: 5000 });
});
test('assertive live region fires for errors', async ({ page }) => {
await page.goto('/checkout');
const errorRegion = page.locator('[aria-live="assertive"], [role="alert"]');
// Submit empty form to trigger validation
await page.click('[type="submit"]');
// Assertive regions must fire immediately — tight timeout is intentional
await expect(errorRegion).not.toBeEmpty({ timeout: 500 });
});The key distinction: polite regions should wait for the user to be idle; assertive regions interrupt immediately. Testing both with appropriate timeouts catches incorrect aria-live values that axe won't flag.
Focus Management After Dynamic Content
When a modal opens, focus must move into it. When it closes, focus must return to the trigger. Axe sees the DOM structure; Playwright tests the actual behavior.
test('modal focus management is correct', async ({ page }) => {
await page.goto('/dashboard');
const triggerButton = page.locator('[data-testid="open-settings"]');
await triggerButton.click();
const modal = page.locator('[role="dialog"]');
await expect(modal).toBeVisible();
// Focus must be inside the modal — not on body, not on trigger
const focusedElement = page.locator(':focus');
await expect(modal).toContainElement(focusedElement);
// Close the modal
await page.keyboard.press('Escape');
await expect(modal).not.toBeVisible();
// Focus must return to the trigger, not reset to top of page
await expect(triggerButton).toBeFocused();
});
test('focus moves to error summary on validation failure', async ({ page }) => {
await page.goto('/registration');
await page.click('[type="submit"]');
// Many forms create an error summary — focus must move there
const errorSummary = page.locator('[role="alert"], .error-summary');
await expect(errorSummary).toBeFocused({ timeout: 1000 });
});Keyboard Trap Detection
WCAG 2.1 SC 2.1.2 requires users can always move focus away from a component using only the keyboard. Detecting traps requires simulating Tab key sequences and verifying focus escapes.
test('no keyboard trap in date picker', async ({ page }) => {
await page.goto('/booking');
const dateInput = page.locator('[data-testid="date-input"]');
await dateInput.click();
// Verify the picker opened
const datePicker = page.locator('[role="dialog"][aria-label*="calendar" i]');
await expect(datePicker).toBeVisible();
// Attempt to Tab out — collect focus destinations
const focusedElements: string[] = [];
for (let i = 0; i < 20; i++) {
await page.keyboard.press('Tab');
const focused = await page.evaluate(() => {
const el = document.activeElement;
return el ? `${el.tagName}#${el.id}.${el.className}` : 'none';
});
focusedElements.push(focused);
// If focus leaves the dialog, no trap
const isInsideDialog = await datePicker.evaluate(
(dialog, activeEl) => dialog.contains(activeEl),
await page.evaluateHandle(() => document.activeElement)
);
if (!isInsideDialog) return; // No trap — test passes
}
// If we get here, focus never left — that's a trap
throw new Error(`Keyboard trap detected in date picker. Focus stayed inside after 20 Tab presses.`);
});Custom axe Rules for Design System Violations
Your design system has conventions axe doesn't know about. Custom rules let you enforce them automatically.
import AxeBuilder from '@axe-core/playwright';
const customRules = [
{
id: 'icon-button-must-have-label',
description: 'Icon-only buttons must have aria-label or aria-labelledby',
selector: 'button:has(svg):not(:has(span:not(.sr-only)))',
tags: ['design-system', 'wcag2a'],
all: [],
any: [
{
id: 'has-aria-label',
evaluate: function(node) {
return node.hasAttribute('aria-label') || node.hasAttribute('aria-labelledby');
}
}
],
none: []
}
];
test('icon buttons all have accessible labels', async ({ page }) => {
await page.goto('/app');
const results = await new AxeBuilder({ page })
.withRules(['icon-button-must-have-label'])
.analyze();
// Custom rule violations get the same treatment as built-in ones
expect(results.violations).toHaveLength(0);
});WCAG 2.2 New Criteria Testing
WCAG 2.2 added several criteria that require behavioral testing. Two of the most important: SC 2.4.11 (Focus Not Obscured) and SC 2.5.3 (Dragging Movements).
SC 2.4.11: Focus Not Obscured
Sticky headers and cookie banners frequently obscure focused elements. Axe can't detect this — it requires viewport geometry checks.
test('focused elements not obscured by sticky header', async ({ page }) => {
await page.goto('/docs');
const stickyHeader = page.locator('header[data-sticky], .sticky-nav');
const headerBottom = await stickyHeader.evaluate(el => el.getBoundingClientRect().bottom);
// Tab through all focusable elements
await page.keyboard.press('Tab');
const links = await page.locator('a, button, input, select, textarea, [tabindex]').all();
for (const link of links.slice(0, 20)) {
await link.focus();
const focusBoundingBox = await link.boundingBox();
if (!focusBoundingBox) continue;
// Focused element's top must be below the sticky header
if (focusBoundingBox.top < headerBottom) {
throw new Error(
`Element obscured by sticky header: ${await link.getAttribute('class')} ` +
`(top: ${focusBoundingBox.top}, header bottom: ${headerBottom})`
);
}
}
});SC 2.5.7: Dragging Movements Alternative
Any drag interaction must have a pointer-based alternative (click, tap, or form input).
test('drag-to-reorder has keyboard alternative', async ({ page }) => {
await page.goto('/tasks');
const firstTask = page.locator('[data-testid="task-item"]').first();
// Verify keyboard reorder controls exist
const moveUpButton = firstTask.locator('[aria-label*="move up" i], [aria-label*="reorder" i]');
await expect(moveUpButton).toBeVisible();
// Verify the button actually works
const taskText = await firstTask.locator('.task-title').textContent();
await moveUpButton.click();
// After moving up, verify order changed (or it's already first)
const newFirstTask = page.locator('[data-testid="task-item"]').first();
const newTaskText = await newFirstTask.locator('.task-title').textContent();
// Either the item moved, or it was already at top — both are valid
expect(taskText).toBeTruthy();
});Reading Order Verification
DOM order and visual order must match (WCAG 1.3.2). CSS Grid and Flexbox reordering frequently breaks this.
test('DOM reading order matches visual order', async ({ page }) => {
await page.goto('/products');
const cards = page.locator('[data-testid="product-card"]');
const count = await cards.count();
const positions = await Promise.all(
Array.from({ length: count }, (_, i) =>
cards.nth(i).boundingBox().then(bb => ({ index: i, top: bb?.top ?? 0, left: bb?.left ?? 0 }))
)
);
// Sort by visual position (top then left for LTR)
const visualOrder = [...positions].sort((a, b) => a.top - b.top || a.left - b.left);
// DOM order should match visual order
visualOrder.forEach((visual, visualIndex) => {
expect(visual.index).toBe(visualIndex);
});
});Putting It Together: A11y Test Suite Structure
Organize behavioral a11y tests separately from axe scans to keep CI output readable:
// accessibility.spec.ts — axe scans (fast, run on every PR)
// accessibility-behavioral.spec.ts — focus, keyboard, live regions (slower, run on merge)
test.describe('Behavioral Accessibility', () => {
test.beforeEach(async ({ page }) => {
// Disable animations — they interfere with focus timing
await page.emulateMedia({ reducedMotion: 'reduce' });
});
// ... your behavioral tests
});Axe catches structural issues in seconds. Behavioral tests catch the interaction failures that users with disabilities actually encounter. Both are required — neither is sufficient alone.