Playwright Accessibility Auditing: Beyond axe-core Basics

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.

Read more