Skip to main content

Playwright Accessibility Testing: Testing with aria-* and Screen Readers

June 2, 2026

</>

Web accessibility is no longer a courtesy feature. As of 2026, WCAG 2.1 Level AA compliance is legally required in the European Union under the European Accessibility Act, and ADA Title III case law has established legal precedent in the United States requiring accessible web applications. The compliance deadline has passed. Lawsuits for inaccessible web applications increased 42% year-over-year in 2025.

Beyond the legal requirement, approximately 16% of the global population lives with some form of disability. An inaccessible application excludes them.

The good news: Playwright integrates with axe-core to automate a significant portion of accessibility auditing directly in your CI pipeline, catching violations before they reach production.


Two Layers of Accessibility Testing

Automated tools can catch approximately 30–40% of accessibility violations. The remaining 60–70% require manual testing with real assistive technology. Both layers are necessary:

ACCESSIBILITY TESTING COVERAGE:
┌─────────────────────────────────────────────────────┐
  Automated (Playwright + axe-core)   ~35% coverage  
  ├── Missing ARIA labels                            
  ├── Insufficient color contrast                    
  ├── Missing alt text on images                     
  ├── Form fields without labels                     
  └── Missing landmark structure                     
├─────────────────────────────────────────────────────┤
  Manual (Screen Reader + Keyboard)   ~65% coverage  
  ├── Logical reading order                          
  ├── Focus management after modals                  
  ├── Custom widget keyboard patterns                
  └── Meaningful vs. decorative element distinction  
└─────────────────────────────────────────────────────┘

This post covers automating the first layer fully and providing a manual testing checklist for the second.


Setting Up axe-core with Playwright

Install the integration package:

npm install -D @axe-core/playwright

Create a reusable accessibility test helper:

// tests/helpers/a11y.ts
import { Page, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

interface A11yOptions {
  /**
   * WCAG standard to test against
   * 'wcag2a' | 'wcag2aa' | 'wcag2aaa' | 'wcag21a' | 'wcag21aa' | 'wcag22aa'
   */
  standard?: string;
  /** CSS selectors to exclude from scan (e.g., third-party widgets) */
  exclude?: string[];
  /** CSS selectors to include only (for component-level testing) */
  include?: string[];
}

export async function checkAccessibility(page: Page, options: A11yOptions = {}) {
  const { standard = 'wcag22aa', exclude = [], include } = options;

  let builder = new AxeBuilder({ page }).withTags([standard]);

  if (exclude.length > 0) {
    builder = builder.exclude(exclude.join(', '));
  }

  if (include) {
    builder = builder.include(include.join(', '));
  }

  const results = await builder.analyze();

  // Log violations with clear, actionable messages
  if (results.violations.length > 0) {
    const report = results.violations.map(violation => ({
      id: violation.id,
      impact: violation.impact,
      description: violation.description,
      helpUrl: violation.helpUrl,
      affectedElements: violation.nodes.map(n => n.target.join(' > ')),
    }));

    console.error('Accessibility violations found:');
    console.error(JSON.stringify(report, null, 2));
  }

  expect(results.violations).toHaveLength(0);
}

Writing Accessibility Tests

Page-Level Audit

// tests/accessibility/pages.spec.ts
import { test } from '@playwright/test';
import { checkAccessibility } from '../helpers/a11y';

test.describe('Page Accessibility', () => {
  test('homepage passes WCAG 2.2 AA', async ({ page }) => {
    await page.goto('/');
    await page.waitForLoadState('networkidle');
    await checkAccessibility(page);
  });

  test('login page passes WCAG 2.2 AA', async ({ page }) => {
    await page.goto('/login');
    await checkAccessibility(page, {
      exclude: ['#third-party-recaptcha'], // Exclude third-party widget
    });
  });

  test('blog post passes WCAG 2.2 AA', async ({ page }) => {
    await page.goto('/blog/sample-post');
    await page.waitForLoadState('networkidle');
    await checkAccessibility(page);
  });
});

Component-Level Audit

// tests/accessibility/components.spec.ts
import { test } from '@playwright/test';
import { checkAccessibility } from '../helpers/a11y';

test.describe('Component Accessibility', () => {
  test('navigation menu passes WCAG 2.2 AA', async ({ page }) => {
    await page.goto('/');
    // Test only the navigation component
    await checkAccessibility(page, { include: ['nav[aria-label="Main Navigation"]'] });
  });

  test('modal dialog is accessible', async ({ page }) => {
    await page.goto('/');
    await page.getByRole('button', { name: 'Open Contact Form' }).click();

    // Wait for modal to be visible
    const modal = page.getByRole('dialog');
    await modal.waitFor({ state: 'visible' });

    // Check dialog-specific accessibility
    await checkAccessibility(page, { include: ['[role="dialog"]'] });

    // Verify focus moved to modal
    await expect(modal).toBeFocused();

    // Verify Escape closes the modal
    await page.keyboard.press('Escape');
    await expect(modal).not.toBeVisible();
  });
});

Interactive Flow Accessibility

test('form submission flow is accessible at every step', async ({ page }) => {
  await page.goto('/contact');

  // Step 1: Empty form state
  await checkAccessibility(page);

  // Step 2: After validation errors
  await page.getByRole('button', { name: 'Submit' }).click();
  await page.waitForSelector('[role="alert"]'); // Error messages
  await checkAccessibility(page);

  // Step 3: After successful submission
  await page.getByLabel('Name').fill('Test User');
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Message').fill('Hello from accessibility tests');
  await page.getByRole('button', { name: 'Submit' }).click();
  await page.waitForSelector('[data-testid="success-message"]');
  await checkAccessibility(page);
});

Keyboard Navigation Testing

Automated axe-core audits don't test logical keyboard flow. Write explicit keyboard navigation tests:

test('entire page is keyboard navigable in logical order', async ({ page }) => {
  await page.goto('/');

  // Start from the top of the page
  await page.keyboard.press('Tab');

  // Verify the skip-to-content link appears first
  const skipLink = page.getByRole('link', { name: 'Skip to main content' });
  await expect(skipLink).toBeFocused();

  // Press Enter to skip navigation
  await page.keyboard.press('Enter');

  // Verify focus moved to main content
  const main = page.locator('main');
  const focusedElement = await page.evaluate(() => document.activeElement?.tagName);
  expect(focusedElement).toBeTruthy();

  // Tab through the main navigation items
  const navLinks = page.locator('nav a');
  const navCount = await navLinks.count();

  // Press Tab and verify each nav item receives focus in DOM order
  for (let i = 0; i < Math.min(navCount, 5); i++) {
    await page.keyboard.press('Tab');
  }
});

test('modal traps focus correctly', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Open Menu' }).click();

  const modal = page.getByRole('dialog');
  await expect(modal).toBeVisible();

  // Tab through all interactive elements — focus must stay within modal
  for (let i = 0; i < 10; i++) {
    await page.keyboard.press('Tab');
    const activeElement = await page.evaluate(() => {
      const el = document.activeElement;
      return el ? el.closest('[role="dialog"]') !== null : false;
    });
    expect(activeElement).toBe(true);
  }
});

GitHub Actions Integration

# .github/workflows/accessibility.yml
name: Accessibility Audit

on:
  pull_request:
    branches: [main]

jobs:
  accessibility:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci
      - run: npx playwright install chromium --with-deps

      - name: Run Accessibility Tests
        run: npx playwright test tests/accessibility/

      - name: Upload Report
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: accessibility-report
          path: playwright-report/

Manual Accessibility Checklist

Automate everything above, then manually verify these with a screen reader (NVDA on Windows, VoiceOver on macOS):

  • [ ] Reading order: Does VoiceOver read the page in the logical visual order?
  • [ ] Heading structure: Is there exactly one <h1>? Do headings not skip levels?
  • [ ] Image alt text: Are decorative images marked alt="" and informative images described?
  • [ ] Link purpose: Do all links make sense when read in isolation (not "click here")?
  • [ ] Form labels: Does every input have a visible and programmatic label?
  • [ ] Error messages: Are validation errors programmatically associated with their input fields?
  • [ ] Status updates: Are dynamic content changes announced via aria-live regions?

Conclusion

Accessibility testing is not a nice-to-have feature to add at launch. It is a legal requirement, a moral obligation, and a quality signal. The 30–40% of violations catchable by automated tooling should be blocked in CI on every PR. The remaining violations require human judgment — a trained tester working through your application with a real screen reader. Together, they ensure that your application is genuinely usable by every person who needs it.

Recommended Posts