All Posts
Cover image for Automating Accessibility Audits in React with CI/CD
accessibilityreactci-cdgithub-actions

Automating Accessibility Audits in React with CI/CD

5 min read

Making web applications accessible isn't just a legal compliance requirement; it's a fundamental part of building quality software. Yet, in many teams, accessibility (A11y) is still treated as an afterthought. We wait until QA or compliance audits find issues, leading to expensive, last-minute refactoring. In my work leading WCAG 2.1 AA accessibility initiatives, I've seen how shifting these checks left by automating them in CI/CD pipelines can reduce production A11y bugs by up to 45%.

Here is how you can build a robust, automated accessibility testing pipeline for React applications using ESLint, axe-core, and GitHub Actions.

The Cost of Manual Accessibility Testing

Manual audits using screen readers and keyboard navigation are crucial, but they are time-consuming and don't scale. If you rely solely on manual testing, you'll inevitably miss issues like missing aria-labels, poor color contrast, or broken heading hierarchies.

By the time a manual audit catches these bugs, the code is already merged, and fixing it requires context switching, re-testing, and redeploying. Shifting these checks left means catching them during local development and blocking them at the Pull Request gate.

Step 1: Catching Basic Errors in Code with ESLint

The first line of defense is static analysis. For React, the eslint-plugin-jsx-a11y plugin is the industry standard. It runs inside your IDE and catches syntax-level accessibility errors as you write code, such as missing alt text on images or interactive elements without keyboard support.

To set it up, install the plugin:

npm install eslint-plugin-jsx-a11y --save-dev

Then, configure it in your .eslintrc.json or ESLint configuration file:

{
  "plugins": [
    "jsx-a11y"
  ],
  "extends": [
    "plugin:jsx-a11y/recommended"
  ],
  "rules": {
    "jsx-a11y/label-has-associated-control": [
      "error",
      {
        "required": {
          "some": ["nesting", "id"]
        }
      }
    ]
  }
}

This ensures basic standards are enforced directly in the developer's editor, saving time before any code is even committed.

Step 2: Component-Level Testing with React Testing Library and Vitest

Static analysis only goes so far. To test interactive states and dynamic components, we need runtime analysis. This is where @testing-library/react combined with vitest-axe (or jest-axe) shines.

Let's look at a custom Accordion component test. We want to ensure that expanding or collapsing the accordion maintains correct semantic structure and ARIA attributes:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe, toHaveNoViolations } from 'vitest-axe';
import { Accordion } from './Accordion';

expect.extend(toHaveNoViolations);

describe('Accordion Accessibility', () => {
  it('should have no accessibility violations in default state', async () => {
    const { container } = render(
      <Accordion title="Security Details">
        <p>Your data is encrypted at rest and in transit.</p>
      </Accordion>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('should support keyboard navigation', async () => {
    render(
      <Accordion title="Security Details">
        <p>Your data is encrypted.</p>
      </Accordion>
    );
    
    const button = screen.getByRole('button', { name: /security details/i });
    expect(button).toHaveAttribute('aria-expanded', 'false');
    
    await userEvent.tab();
    expect(button).toHaveFocus();
    
    await userEvent.keyboard('{Enter}');
    expect(button).toHaveAttribute('aria-expanded', 'true');
  });
});

Using axe in your unit and integration tests catches the vast majority of accessibility bugs (such as color contrast, form label associations, and role mappings) before they leave your local machine.

Step 3: End-to-End Auditing with Playwright and Axe

Some accessibility issues only manifest when components interact or when page-level layouts are rendered. For these, we run automated E2E audits using Playwright and @axe-core/playwright.

This setup loads the fully rendered app in a headless browser, injects axe-core, runs a full page scan, and reports violations. Here's a Playwright script that audits a login page:

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

test.describe('Login Page A11y Audit', () => {
  test('should pass automated accessibility scan', async ({ page }) => {
    await page.goto('/login');
    
    // Wait for the form to render
    await page.waitForSelector('form');

    const accessibilityScanResults = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag21aa'])
      .analyze();

    expect(accessibilityScanResults.violations).toEqual([]);
  });
});

This captures page-level issues like document language properties, focus trapping inside modals, and bypass blocks (skip links).

Step 4: Enforcing at the PR Gate via GitHub Actions

Now that we have local checks, we must prevent regressions by running these checks on every Pull Request. We configure a GitHub Actions workflow to run our ESLint checks, unit tests, and E2E audits.

Here is a sample .github/workflows/a11y-check.yml workflow:

name: Accessibility CI

on:
  pull_request:
    branches: [ main ]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install Dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint

      - name: Run Unit & A11y Tests
        run: npm run test:run

      - name: Run E2E A11y Scan
        run: npx playwright test

By making this workflow required, no PR can be merged unless all automated accessibility checks pass, ensuring that your production environment remains clean and compliant.

Key Takeaways

  1. Start Early: Catching A11y issues in ESLint or component-level unit tests is infinitely cheaper than fixing them post-merge.
  2. Combine Static and Dynamic Checks: Use ESLint for static syntax checks, axe inside RTL for component states, and Playwright for page-level verification.
  3. Automate in CI/CD: Never rely on manual processes to run tests. Enforce passing accessibility audits as a blocker for merging code.
  4. Remember the Limits: Automated tools catch about 30-50% of accessibility issues. Use them as a baseline so your manual QA team can focus on complex screen-reader interactions and logical tab orders.
Share
Himanshu Shrivastava avatar

Himanshu Shrivastava

Senior Full Stack Engineer · Node.js · React · TypeScript · AWS · Accessibility

More Posts