We all know accessibility matters, and yet a11y checks are still the first thing to slip when a release deadline looms. Running four small projects in parallel, I have personally pushed "just one more sprint" enough times that the regressions piled up and forced an embarrassing catch-up week.
This piece walks through a concrete way to keep the audit in the loop without relying on willpower. We use axe-core as the runtime, and Claude Code as the connective tissue that turns raw output into something a developer actually sees and acts on.
Why "we installed an audit tool" usually fails
Most teams already have a linter, a test runner, and probably a Lighthouse step somewhere. The reason a11y still slides is not that the tooling is missing. It is that the results live in the wrong place.
A warning buried in a CI log gets ignored. A Slack notification gets scrolled past. The only spot a developer reliably reads is the diff view of the pull request they just opened. Once I started piping audit results into PR comments instead of CI logs, the same warnings that used to be ignored for weeks were fixed within hours.
Claude Code earns its keep here in two ways. First, it converts noisy axe-core output into a focused summary that says what to fix and why. Second, hooks and skills let the same check run during local commits, so the issue is caught before the PR even exists.
Step 1: Run axe-core headlessly with Playwright
Start with the smallest possible runtime. Playwright + @axe-core/playwright is the most stable combination I have used.
npm install -D @axe-core/playwright playwright @playwright/test
npx playwright install chromiumNow write one small script with a single responsibility — visit a URL, run the audit, write the result to disk. Keep it boring; clever scripts are the ones that rot.
// scripts/a11y-audit.mjs
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import { writeFileSync } from 'node:fs';
const targets = process.argv.slice(2);
if (targets.length === 0) {
console.error('Usage: node scripts/a11y-audit.mjs <url> [<url> ...]');
process.exit(1);
}
const browser = await chromium.launch();
const page = await browser.newPage();
const results = [];
for (const url of targets) {
await page.goto(url, { waitUntil: 'networkidle' });
const r = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa']) // start at AA
.analyze();
results.push({ url, violations: r.violations });
}
await browser.close();
writeFileSync('a11y-report.json', JSON.stringify(results, null, 2));
console.log(`Found ${results.reduce((n, r) => n + r.violations.length, 0)} violations across ${results.length} pages.`);The expected outputs are an a11y-report.json file in the repo root and a one-line summary on stdout. Both local runs and CI use exactly the same command, which keeps the operating model dead simple.
Step 2: Wrap the script as a Claude Code skill
A Claude Code skill is a tiny manual the model can invoke autonomously. Full mechanics are covered in the custom skills development guide; here is the minimum we need.
# .claude/skills/a11y-audit/SKILL.md
---
name: a11y-audit
description: "Run axe-core against a URL and summarize violations by impact. Triggers: accessibility, a11y, audit"
---
# How to use
1. Make sure `npm run dev` is running.
2. Run `node scripts/a11y-audit.mjs http://localhost:3000`.
3. Read `a11y-report.json` and bucket violations into critical / serious / moderate / minor.
4. For each critical and serious item, return the affected selector, the WCAG criterion, and a one-line fix suggestion.
5. Format the output as a Markdown block that can be pasted into a PR description.Two things matter here. First, the trigger keywords in the description — without them, Claude Code cannot decide when to invoke the skill. Second, leave the judgment to the model. Don't try to encode "what counts as critical" in the skill itself; teams disagree, and hardcoded rules age badly.
Step 3: Add a pre-commit hook so nothing slips
Local audits only help if you remember to run them, and you won't. A Claude Code hook fixes that. The full hook system is documented in the hooks automation guide; here is the relevant snippet.
// .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash(git commit*)",
"command": "node scripts/a11y-audit.mjs http://localhost:3000 || true",
"description": "Run a11y audit before commit (does not block the commit)"
}
]
}
}The || true is intentional. A hook that blocks commits will be disabled within a week. A hook that surfaces information without punishing the developer will quietly do its job for years. Claude Code summarizes the result in the conversation, the developer sees it, and that is enough to change behavior over time.
Step 4: Surface the result on every pull request
The last piece is a GitHub Actions job that runs the same script and posts a comment. Broader Claude Code + Actions patterns are in this workflow guide; the audit-only version is short.
# .github/workflows/a11y.yml
name: A11y Audit
on:
pull_request:
branches: [main]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build && npm run start &
- run: sleep 5 && node scripts/a11y-audit.mjs http://localhost:3000
- name: Comment summary on PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const data = JSON.parse(fs.readFileSync('a11y-report.json', 'utf8'));
const counts = data.flatMap(p => p.violations).reduce((acc, v) => {
acc[v.impact] = (acc[v.impact] || 0) + 1;
return acc;
}, {});
const body = [
'## Accessibility audit',
`- critical: ${counts.critical || 0}`,
`- serious: ${counts.serious || 0}`,
`- moderate: ${counts.moderate || 0}`,
`- minor: ${counts.minor || 0}`,
'',
counts.critical || counts.serious
? '⚠️ Critical or serious issues remain. Please address before merging.'
: '✅ No critical or serious violations.',
].join('\n');
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});Because the raw a11y-report.json ships as a workflow artifact, you can also feed it back into Claude Code later to prioritize a refactor sprint. That dual use — fresh signal in the PR, persisted data for planning — is what makes the setup pay off.
Three traps that will eat 30 minutes each
I tripped on all of these. None are obvious from the docs.
SPA hydration timing. Calling axe-core immediately after page.goto() on a client-rendered app produces phantom violations because the audit runs before React mounts. waitUntil: 'networkidle' solves it; add it before you debug anything else.
Playwright binary in CI. The Chromium download is roughly 200 MB. Without caching it, every CI run pays the tax. Add actions/cache@v4 keyed on the Playwright version and the second run drops by minutes.
Too much signal at once. Reporting every minor violation from day one is overwhelming, and overwhelmed developers ignore the comment entirely. Start with critical and serious only. Lower the bar after the team has cleared the top tier — the same staged approach we use for ESLint cleanup with Claude Code.
A note on what to skip in the early days
When I first set this up, I tried to be thorough. I instrumented every page in the app, ran the audit on multiple viewport widths, and asked Claude Code to summarize all four impact levels. The PR comments became 400 lines long, and the team's reaction was exactly what you'd expect — they collapsed the comment and stopped looking.
The version that actually changed behavior is much smaller. One target URL (the most common landing page), one viewport, only critical and serious violations, and a four-line summary at the top of the comment. Everything else can be added later, after the team has built the habit of opening the section.
This is the same dynamic I see with most "quality gate" tooling. The configuration that survives contact with reality is always less ambitious than the one we want to set up on day one. The audit you can sustain for six months beats the audit you abandon in three weeks.
Where Claude Code earns its place
A reasonable question is whether you need Claude Code at all. axe-core has its own reporters; GitHub Actions can post comments without any LLM in the loop. So why route the result through a model?
The honest answer is that the value shows up over time, not on day one. After two months of running this, the most useful output isn't the per-PR comment — it's being able to ask "show me all the color-contrast violations we've flagged this quarter" or "which routes have regressed since the redesign?" Claude Code reads the accumulated report artifacts, cross-references the affected selectors with the current codebase, and produces a prioritized refactor list. That kind of analysis is awkward to script and natural to ask.
If you're starting fresh, I'd suggest installing the script and the GitHub Action first, and adding the skill once you have a few weeks of reports to look back on. That way the skill is doing work that would otherwise be tedious, instead of duplicating what the JSON already shows.
Stop relying on memory; start relying on the workflow
Accessibility lives or dies on whether the feedback shows up where developers already look. Tools alone don't fix it. A small skill, a forgiving hook, and a PR comment together do.
If you want a single concrete next step, drop scripts/a11y-audit.mjs into your project and run it once against localhost:3000. The first violations you see will tell you whether you want the hook next, the Action next, or both at once. The order does not matter as much as starting today instead of next quarter.