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 scoreCommon 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.