Skip to content
Back to Quality Assurance

Accessibility in CI

Automate a11y checks in the build pipeline so regressions are caught at the PR, not after launch.

Why

Accessibility regresses silently — a developer adds a <div onClick> instead of a <button>, a designer picks a new gray that fails contrast, a refactor removes an aria-label — and none of these are caught by functional tests. By the time someone runs a manual a11y audit (annually, if at all), the app has accumulated 50 violations, and fixing them is a major effort. CI automation catches the regression at the PR: axe-core runs on every page, jsx-a11y lints every file, and the build fails if a new violation is introduced. This turns a11y from a periodic scramble into a maintained baseline — the same shift-left that tests and linting provide for bugs and code quality. The cost is a few minutes of CI time per PR; the benefit is an app that never accumulates a11y debt.

When

From day one of the project. Add axe-core and jsx-a11y to CI before the first PR, so a11y is a baseline, not a retrofit.

How

1. Add eslint-plugin-jsx-a11y for lint-time checks
   - Catches issues at write time, in the editor, before the PR
   - Rules: alt-text, aria-role, label-has-associated-control, no-static-element-interactions
   - Faster than axe-core (no browser needed), catches ~20% of issues

2. Add axe-core to Playwright integration tests
   - Run after navigating to each page, assert no violations
   - withTags(["wcag2a", "wcag2aa"]) for Level A and AA
   - Disable rules that are false positives (withRules) with a documented reason
   - Catches ~30% of issues, including the ones jsx-a11y misses

3. Add Lighthouse a11y audit to CI
   - lighthouse-ci checks the a11y score on a deployed preview
   - Set a minimum score (e.g., 90) and fail the build if it drops
   - Catches contrast, alt, and label issues on the rendered page

4. Handle violations
   - Fix the violation (preferred — the code is wrong)
   - Or disable the rule with a documented reason (for false positives)
   - Never disable globally — disable per-element with a comment explaining why

5. Track the a11y score over time
   - Post the Lighthouse a11y score on each PR (like a test coverage badge)
   - Alert if the score drops by more than 5 points
   - Review the score trend in retrospectives

Example

// 1. eslint-plugin-jsx-a11y (lint-time)
// .eslintrc.json
{
  "extends": ["plugin:jsx-a11y/recommended"],
  "rules": {
    "jsx-a11y/no-autofocus": "error",
    "jsx-a11y/click-events-have-key-events": "error",
    "jsx-a11y/no-static-element-interactions": "error"
  }
}

// 2. axe-core in Playwright (integration)
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

const pages = ["/", "/products", "/cart", "/checkout", "/account"];

for (const path of pages) {
  test(`${path} has no a11y violations`, async ({ page }) => {
    await page.goto(path);
    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa"])
      .disable(["color-contrast"]) // disabled with a reason: tested separately
      .analyze();
    expect(results.violations).toEqual([]);
  });
}

// 3. Lighthouse CI
// lighthouserc.json
{
  "ci": {
    "collect": {
      "url": ["https://staging.example.com/", "https://staging.example.com/checkout"]
    },
    "assert": {
      "assertions": {
        "categories:accessibility": ["error", { "minScore": 0.9 }]
      }
    }
  }
}

// 4. Handle a false positive (disable per-element, not globally)
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
// Reason: this div is a drop zone for drag-and-drop, which is keyboard-accessible
// via a separate button. See issue #1234.
<div onDrop={handleDrop} />

// 5. CI workflow
// .github/workflows/a11y.yml
name: Accessibility
on: [pull_request]
jobs:
  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx eslint .  # jsx-a11y rules
      - run: npx playwright test  # axe-core checks
      - run: npx lhci autorun  # Lighthouse a11y score

Common Problems

  • ⚠Adding axe-core to CI but disabling every failing rule to make the build pass — the check runs but catches nothing, and the team believes a11y is "handled" while violations accumulate.
  • ⚠Running axe-core on only the homepage and missing the checkout, account, and admin pages — the homepage passes, but the checkout flow (where the user spends money) has 15 violations that ship to production.
  • ⚠Setting the Lighthouse a11y score threshold to 50 "to get started" and never raising it — the score stays at 50, the app is half-accessible, and the threshold provides no pressure to improve.
  • ⚠Treating axe-core violations as the only a11y work — the CI passes, but nobody does keyboard or screen reader testing, and the 70% of issues that axe cannot catch ship to production.

Best Practices

  • ✓Add jsx-a11y (lint) + axe-core (integration) + Lighthouse (score) to CI on day one — the layered automation catches the mechanical issues at the PR, and manual testing catches the rest before launch.
  • ✓Run axe-core on every page, not just the homepage — create a page list and loop over it, so the checkout and account pages are checked with the same rigor as the landing page.
  • ✓Never disable a rule globally — disable per-element with a comment explaining the reason and linking to an issue, so the disable is visible in review and can be removed when the underlying issue is fixed.
  • ✓Treat CI a11y checks as the floor, not the ceiling — they catch 30% of issues, and the other 70% require manual keyboard and screen reader testing that must be scheduled before every release.