Accessibility in CI/CD: catching WCAG violations before they hit main
Most a11y bugs are found after release, by customers or lawyers. Move the check left with GitHub Actions so broken contrast and missing labels never reach production.
Every team I talk to has the same story. An accessibility audit lands on someone's desk — usually because a customer complained, a procurement questionnaire asked for a VPAT, or a lawyer sent a letter. The team spends two weeks running axe in the browser, opens forty tickets, fixes half of them, ships, and promises to "keep an eye on it." Six months later, another audit finds the same issues plus twenty new ones introduced during the intervening sprints.
The problem is not effort. Most engineering teams I meet care about accessibility. The problem is timing. Accessibility checks that run at the end of a cycle catch issues that are expensive to fix. Accessibility checks that run on every pull request catch issues while the code is still fresh, the author still has context, and the fix is one commit away from the mistake.
This post is about wiring that second pattern into GitHub Actions. No new tools to learn, no audit engagement to schedule — just a workflow file that fails the build when a contributor ships an unlabeled form field or a 2:1 contrast ratio.
Why "end of sprint" a11y testing fails
Consider a typical release: ten pull requests land during a two-week sprint, each touching different parts of the UI. At the end of the sprint, QA or a contractor runs axe on the staging build and reports forty violations. What happens next?
The team cannot tell which PR introduced which violation without bisecting. The original authors have moved on. Somebody is assigned to "fix accessibility," which in practice means a long-running branch full of drive-by changes. Reviewers struggle because the context is scattered. Some fixes land, some regress, and the whole thing takes longer than it should.
If the same forty violations had surfaced one-at-a-time on the PRs that caused them, you would have had forty small, contextual fixes instead of one sprawling remediation epic. That is what CI accessibility testing buys you: it turns accessibility from a project into a normal part of code review.
The pieces of a real a11y pipeline
A working accessibility CI pipeline has four parts. Most teams stop after the first one.
Automated rule checks. Static and runtime rule engines — axe-core is the reference implementation — that check your rendered DOM against a known ruleset. Fast, deterministic, catches ~30-40% of WCAG violations on their own (Deque's published figure; check their site for the latest methodology).
Visual and keyboard checks. Things like focus ring visibility, skip-link targets, and reading order that static tools cannot verify. Usually means a headless browser run plus some assertions.
Source-level checks. Patterns you can catch without rendering — aria-hidden="true" on an interactive element, a JSX component with an onClick but no role, an <img> with no alt prop. Linters, ESLint plugins, or custom rules.
Compliance reporting. A human-readable summary of what was tested, what passed, what failed, and what severity each violation is. Needed when procurement or legal asks for proof of due diligence.
A lot of teams bolt on axe-core and call it done. That catches the easy wins but leaves compliance-grade reporting and source-level scanning unowned. We will come back to that.
Minimum viable GitHub Actions workflow
Here is the smallest workflow that does something useful. Drop this in .github/workflows/a11y.yml:
name: accessibility
on:
pull_request:
branches: [main]
jobs:
axe-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Start preview server
run: npm run preview &
env:
PORT: 4173
- name: Wait for server
run: npx wait-on http://localhost:4173
- name: Run axe
run: npx @axe-core/cli http://localhost:4173 --exit --tags wcag2a,wcag2aa,wcag21aa,wcag22aa
That will fail the build whenever axe finds a WCAG 2.2 AA violation on your preview URL. Three things to notice:
The --exit flag is what makes the check blocking. Without it, axe reports and cheerfully exits zero. Teams sometimes omit this so they can "see the output" for a while. That is how you end up six months later with an informational workflow that everyone ignores.
The --tags filter scopes the run to WCAG 2.2 AA plus the earlier levels it builds on. Axe's default ruleset includes best-practice rules that are not WCAG violations. If you want a clean compliance signal, filter to the tags you actually care about.
This only scans your homepage. That is not enough.
Going beyond the homepage
Most of your accessibility violations live on pages your axe-cli command never visits. Forms, authenticated dashboards, modal flows, error states. If the scan only hits /, you are grading on a curve.
Three ways to widen coverage, roughly in order of effort:
Sitemap crawl. Feed axe a list of routes. Works for unauthenticated marketing pages. Breaks down once auth or state matters.
Storybook. If you have a Storybook, every story is a testable isolated page. @storybook/addon-a11y runs axe on each story; in CI you can run the addon's test runner to get per-component pass/fail. This is the highest-value per-hour setup for most component teams, because you get component-level accessibility coverage for free and it cuts noise from layout-level issues.
Playwright with axe-core/playwright. Integrates accessibility assertions into your existing e2e tests. The pattern is to navigate to a page in the authenticated state your test sets up, then call AxeBuilder(page).analyze() and assert no violations. This covers interactive states — modals open, errors showing, forms mid-submission — that static crawls miss.
You probably want all three eventually. Start with Storybook if you have one, Playwright if you have e2e tests, sitemap crawl only if you have a mostly-static site.
The three things axe will not catch
Axe is a rule engine. It is excellent at rules and bad at everything else. Three categories of violation will pass an axe-clean build and still fail a real audit:
Semantic ambiguity. A <div role="button"> that has a tabindex and a click handler will pass axe. Whether the surrounding HTML structure actually communicates the intent to a screen reader user is a judgment call. Axe cannot make it.
Reading order and focus order. Axe checks that focusable elements have visible focus indicators. It does not check that the tab order makes sense — that pressing Tab does not jump from the header to the footer and back up to the sidebar.
Content quality. Alt text that says "image" instead of describing the image passes the rule. Error messages that say "invalid input" instead of explaining what went wrong pass the rule. Labels that say "Field 1" pass the rule. None of these pass a usability test with a screen reader user.
You cannot automate these away entirely. But you can reduce the surface area by running source-level checks — eslint-plugin-jsx-a11y, stylelint rules for outline: none without a replacement, grep rules for generic alt text — alongside axe.
What to do when violations have already shipped
Most teams adding accessibility CI are not starting green. You turn on the gate and it immediately fails on main. Two paths from there.
The brittle path: add everything to an allowlist, then chip away at the list over time. Works if you have real discipline about the list. Falls apart the moment someone silences a "flaky" a11y test.
The sustainable path: baseline the current violations, gate only on new ones, then budget a percentage of each sprint to paying the existing debt down. Axe supports this via the ignore option; Playwright's integration lets you exclude known-bad selectors. The Storybook runner can be configured to report violations but not fail the job for specific stories.
Either way, make the existing debt visible. A README in .a11y/ with the list of known violations, linked to tickets, dated, and owned. If you hide the list in a yaml allowlist nobody will ever look at, it will rot.
Where inklu fits
Everything above is free to build and maintain. You should build it. Axe-core, Storybook, Playwright, and GitHub Actions will get you 80% of the way to a pipeline that catches regressions.
The 20% that an open-source pipeline will not do is the part inklu exists to handle: turning a detected violation into a pull request that actually fixes it. Finding violations is the easier half. Fixing them — correctly, in the idiomatic style of your codebase, across HTML, CSS, JSX, and SCSS — is where most teams stall out. A CI workflow that reports "12 violations on this PR" does not reduce the team's workload; it just relocates the work.
Inklu plugs into the same GitHub Actions setup. It scans against WCAG 2.2 using axe-core v4.11 plus 50+ proprietary rules that cover patterns axe's defaults miss, then opens pull requests with AI-generated fixes. Source code is purged after each scan — we never retain your repo. For teams already running axe in CI, inklu is the next layer: instead of tickets, you get diffs.
If you want to see how the compare math works against other scan-and-report tools, our comparison with axe DevTools lays out the differences plainly. Pricing is at /pricing — token-based, so small PRs cost a dollar or two and large repo scans are predictable.
Start small
You do not need a six-month accessibility initiative to start. Add the workflow file above to one repo this week. Scope it to one route or one Storybook story. Watch it catch something. Expand.
The teams with the fewest accessibility issues in production are not the ones that schedule quarterly audits. They are the ones whose pull requests cannot merge with a contrast ratio of 2.3:1, because the pipeline will not let them.
Book a demo at inklu.io or email hello@inklu.io if you want to see the PR-based fix workflow on your own code.