@axe-core/playwright runs accessibility checks alongside your regular Playwright tests, catching missing alt text, unlabeled form inputs, insufficient color contrast, and incorrect ARIA usage on every CI commit. Automated scanning catches roughly 30-40% of WCAG violations; the rest require manual keyboard and screen reader testing. This article covers the full setup: scanning full pages, specific components, filtering by WCAG level, and testing keyboard navigation flows.

Why Automate Accessibility Testing?

Manual accessibility audits are slow and expensive. Automated checks catch the most common issues instantly:

  • Missing alt text on images
  • Form inputs without labels
  • Insufficient color contrast
  • Missing ARIA roles and attributes
  • Keyboard navigation issues
  • Focus management problems

Automated tools catch ~30-40% of accessibility issues. The rest require manual testing with actual assistive technologies. But catching 30-40% automatically, on every commit, is far better than nothing.

Setup: axe-playwright

npm install --save-dev @axe-core/playwright

Basic Accessibility Scan

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('home page has no accessibility violations', async ({ page }) => {
  await page.goto('/');
  
  const results = await new AxeBuilder({ page }).analyze();
  
  expect(results.violations).toEqual([]);
});

If there are violations, the test fails with details:

● home page has no accessibility violations

  expect(received).toEqual(expected)
  
  Expected: []
  Received: [
    {
      id: 'color-contrast',
      description: 'Elements must have sufficient color contrast',
      nodes: [{ target: ['#nav-link'], ... }]
    }
  ]

Scanning Specific Pages

test.describe('Accessibility checks', () => {
  const pages = [
    { name: 'Home', url: '/' },
    { name: 'Login', url: '/login' },
    { name: 'Products', url: '/products' },
    { name: 'Contact', url: '/contact' },
  ];

  for (const { name, url } of pages) {
    test(`${name} page is accessible`, async ({ page }) => {
      await page.goto(url);
      await page.waitForLoadState('networkidle');
      
      const results = await new AxeBuilder({ page }).analyze();
      expect(results.violations).toEqual([]);
    });
  }
});

Filtering Rules

Run only specific WCAG criteria, or exclude known issues:

test('login page WCAG AA compliant', async ({ page }) => {
  await page.goto('/login');
  
  const results = await new AxeBuilder({ page })
    // Only check WCAG 2.1 AA criteria
    .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
    .analyze();
  
  expect(results.violations).toEqual([]);
});

test('products page with known issues excluded', async ({ page }) => {
  await page.goto('/products');
  
  const results = await new AxeBuilder({ page })
    // Temporarily exclude a known issue while it's being fixed
    .disableRules(['color-contrast'])
    .analyze();
  
  expect(results.violations).toEqual([]);
});

Scanning Part of the Page

test('navigation menu is accessible', async ({ page }) => {
  await page.goto('/');
  
  const results = await new AxeBuilder({ page })
    .include('#main-navigation')  // Only scan the nav
    .analyze();
  
  expect(results.violations).toEqual([]);
});

test('modal dialog is accessible', async ({ page }) => {
  await page.goto('/products');
  await page.click('[data-testid="add-to-cart"]');
  
  // Wait for modal to open
  await page.waitForSelector('[role="dialog"]');
  
  const results = await new AxeBuilder({ page })
    .include('[role="dialog"]')
    .analyze();
  
  expect(results.violations).toEqual([]);
});

Keyboard Navigation Testing

Axe catches missing ARIA attributes. Manual keyboard testing finds navigation flow problems:

test('login form is keyboard navigable', async ({ page }) => {
  await page.goto('/login');
  
  // Tab through the form fields
  await page.keyboard.press('Tab');
  await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'email-input');
  
  await page.keyboard.press('Tab');
  await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'password-input');
  
  await page.keyboard.press('Tab');
  await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'submit-btn');
  
  // Submit with Enter key
  await page.keyboard.press('Enter');
  await page.waitForURL('/dashboard');
});

test('modal can be closed with Escape', async ({ page }) => {
  await page.goto('/products');
  await page.click('[data-testid="filter-btn"]');
  
  await expect(page.getByRole('dialog')).toBeVisible();
  
  await page.keyboard.press('Escape');
  
  await expect(page.getByRole('dialog')).not.toBeVisible();
});

Focus Management

When modals open, focus should move inside them. When they close, focus should return:

test('modal traps focus correctly', async ({ page }) => {
  await page.goto('/');
  
  // Open modal
  const triggerButton = page.getByTestId('open-modal');
  await triggerButton.click();
  
  const modal = page.getByRole('dialog');
  await expect(modal).toBeVisible();
  
  // Focus should be inside modal
  const focusedElement = page.locator(':focus');
  await expect(modal).toContainElement(focusedElement);
  
  // Tab through modal — focus shouldn't escape
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');
  
  // Still inside modal
  await expect(modal).toContainElement(page.locator(':focus'));
  
  // Close modal
  await page.keyboard.press('Escape');
  
  // Focus returns to trigger button
  await expect(triggerButton).toBeFocused();
});

Image Alt Text Check

test('all product images have alt text', async ({ page }) => {
  await page.goto('/products');
  
  // Find all images
  const images = page.locator('img');
  const count = await images.count();
  
  for (let i = 0; i < count; i++) {
    const img = images.nth(i);
    const alt = await img.getAttribute('alt');
    const src = await img.getAttribute('src');
    
    // Alt should exist and not be empty string (unless decorative with role="presentation")
    const role = await img.getAttribute('role');
    if (role !== 'presentation') {
      expect(alt, `Image ${src} is missing alt text`).not.toBeNull();
      expect(alt, `Image ${src} has empty alt text`).not.toBe('');
    }
  }
});

ARIA Roles and Labels

test('form inputs have labels', async ({ page }) => {
  await page.goto('/contact');
  
  const inputs = page.locator('input, textarea, select');
  const count = await inputs.count();
  
  for (let i = 0; i < count; i++) {
    const input = inputs.nth(i);
    const type = await input.getAttribute('type');
    
    // Skip hidden inputs
    if (type === 'hidden') continue;
    
    const id = await input.getAttribute('id');
    const ariaLabel = await input.getAttribute('aria-label');
    const ariaLabelledBy = await input.getAttribute('aria-labelledby');
    
    // Input must have a label (via id+label, aria-label, or aria-labelledby)
    const hasLabel = id ? await page.locator(`label[for="${id}"]`).count() > 0 : false;
    
    const isLabelled = hasLabel || ariaLabel || ariaLabelledBy;
    expect(isLabelled, `Input without label: ${id || 'unnamed'}`).toBeTruthy();
  }
});

Generating Accessible HTML Reports

import AxeBuilder from '@axe-core/playwright';

// Custom helper that formats violations nicely
async function checkAccessibility(page, selector?: string) {
  const builder = new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa']);
  
  if (selector) builder.include(selector);
  
  const results = await builder.analyze();
  
  if (results.violations.length > 0) {
    const report = results.violations.map(v => 
      `\n[${v.impact?.toUpperCase()}] ${v.id}: ${v.description}\n` +
      v.nodes.map(n => `  - ${n.target.join(', ')}: ${n.failureSummary}`).join('\n')
    ).join('\n');
    
    throw new Error(`Accessibility violations found:\n${report}`);
  }
}

test('dashboard is accessible', async ({ page }) => {
  await page.goto('/dashboard');
  await checkAccessibility(page);
});

Common Accessibility Issues Found in Automation

| Issue | axe rule | Fix |

|-------|----------|-----|

| Image no alt | image-alt | Add alt="description" |

| Low contrast | color-contrast | Use contrast ratio ≥ 4.5:1 |

| Input no label | label | Add or aria-label |

| Button no text | button-name | Add text or aria-label |

| Heading order | heading-order | Don't skip h1→h3 |

| Missing lang | html-has-lang | Add |

| Link no name | link-name | Add descriptive link text |

Summary

# Install
npm install --save-dev @axe-core/playwright

# Basic usage
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);

# Filter by WCAG level
.withTags(['wcag2a', 'wcag2aa'])

# Scope to element
.include('#main-nav')

# Exclude known issue
.disableRules(['color-contrast'])

Run accessibility checks on your critical pages the same way you run functional tests — on every PR, automatically. Combined with keyboard navigation tests and focus management checks, you catch the majority of issues before they reach users who depend on assistive technology.

→ See also: Accessibility Testing for QA Engineers: Tools, Techniques, and the EAA 2025 Deadline | Keyboard and Mouse Events in Playwright | AI Visual Regression Testing: Beyond Pixel-Perfect Screenshots