TL;DR
I pointed an AI coding agent at a React dashboard with 412 accessibility violations and let it fix them. It closed 78% of them in about six hours of wall-clock time β and it also confidently generated ARIA that made two components worse. Here's the loop that worked, the guardrails that caught the bad output, and the five things I'd tell anyone trying this.
The Problem
We shipped an internal analytics dashboard fast. Sixty-ish React components, three years of "we'll fix that later," and then a client asked for a VPAT before renewal. π
I ran an automated audit and got the number nobody wants to see:
412 violations across 27 pages
23 distinct rule IDs
Worst offenders:
button-name 97
color-contrast 88
label 61
aria-required-attr 34
link-name 29
Estimating the manual fix at 2β4 minutes per violation puts you somewhere between 14 and 27 hours of grinding, and that's the optimistic read β it ignores the fact that half of these need you to open the component, understand what the button actually does, and pick a name a screen reader user would find useful.
So my first instinct was the obvious one. I opened Claude Code, pointed it at the repo, and typed:
Fix all the accessibility issues in
src/components/.
That was a mistake, and it's worth explaining exactly why it was a mistake, because it's the same failure mode I keep seeing people hit with agents on any large mechanical task.
The agent had no ground truth. It didn't know which 412 things were broken β it just knew "accessibility" as a concept. So it did what a well-meaning junior does when handed a vague mandate: it sprayed aria-label onto everything it saw, including elements that already had accessible names, and produced a 900-line diff I couldn't review. Worse, the violation count went up to 431, because aria-label on a <div role="button"> introduced new required-attribute failures that weren't there before.
The lesson landed hard: the agent wasn't bad at the task. I'd given it no way to tell whether it was winning.
How I Solved It
The fix was to stop treating this as "write some code" and start treating it as "close a measurable gap." Three changes.
1. Make the violations machine-readable before the agent sees them
The audit tool's HTML report is for humans. The agent needs structured data it can filter, group, and diff against. I wrote a ~40-line script (axe-core 4.10.x driven through Playwright 1.4x on Node.js 22.x) that walks every route and dumps JSON:
// scripts/a11y-scan.mjs
import { chromium } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
import { writeFileSync } from 'node:fs';
import { ROUTES } from './routes.mjs';
const browser = await chromium.launch();
const page = await browser.newPage();
const findings = [];
for (const route of ROUTES) {
await page.goto(`http://localhost:5173${route}`, { waitUntil: 'networkidle' });
const { violations } = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
for (const v of violations) {
for (const node of v.nodes) {
findings.push({
route,
rule: v.id,
impact: v.impact, // minor | moderate | serious | critical
selector: node.target.join(' '),
html: node.html,
fix: node.failureSummary, // axe's own remediation hint
});
}
}
}
writeFileSync('a11y-findings.json', JSON.stringify(findings, null, 2));
await browser.close();
console.log(`${findings.length} violations written`);
That failureSummary field turned out to be the single highest-leverage thing in the whole setup. It's axe telling you, per node, what specifically is missing. Feeding that to the agent is night-and-day better than feeding it the rule name.
2. Batch by rule, not by file
This is the part I got wrong for the first two hours. My instinct was to go file by file β it matches how you'd assign the work to people. But an agent's failure rate is dominated by context switching between kinds of reasoning, not by how many files it touches.
412 violations collapsed into 23 rules. Within a single rule, the fix is nearly identical every time. So I ran one agent session per rule, with all the affected nodes for that rule in the prompt:
Rule: button-name (97 nodes, impact: critical)
Every node below is an interactive element with no accessible name.
Constraints:
- Prefer visible text content. Only use aria-label when the control is icon-only.
- Never add ARIA to an element that already has an accessible name.
- If you cannot determine the button's purpose from surrounding code,
add it to UNRESOLVED.md instead of guessing.
Nodes:
[ ...the filtered JSON... ]
That third constraint is the important one. Giving the agent a legitimate way to say "I don't know" is what stops it from inventing. 31 of the 97 buttons landed in UNRESOLVED.md β mostly icon-only controls in a chart toolbar whose purpose genuinely wasn't inferable from the JSX. Those were exactly the ones I'd have wanted to review anyway.
3. Close the loop with the scanner, not with my eyeballs
The agent doesn't get to declare victory. The scanner does:
flowchart TD
A[Scan: axe -> findings.json] --> B{Violations for this rule?}
B -- none --> F[Rule closed]
B -- some --> C[Agent fixes batch]
C --> D[Re-scan this rule only]
D --> E{Count decreased?}
E -- yes --> B
E -- no or increased --> G[Revert batch, flag for human]
G --> F
The no or increased -> revert branch fired four times. That branch is the entire reason this project didn't turn into a cleanup job bigger than the original problem. Two of those were the ARIA case I mentioned up top: the agent added role="button" plus aria-pressed to a <div> that should simply have been a <button>, which satisfied its mental model of "accessible" while introducing a keyboard trap.
Here's the guard, and it's boring on purpose:
before=$(jq --arg r "$RULE" '[.[] | select(.rule==$r)] | length' a11y-findings.json)
node scripts/a11y-scan.mjs
after=$(jq --arg r "$RULE" '[.[] | select(.rule==$r)] | length' a11y-findings.json)
if [ "$after" -ge "$before" ]; then
echo "REGRESSION on $RULE: $before -> $after"
git checkout -- src/
exit 1
fi
Three lines of shell that made an autonomous agent safe to leave alone. Cheap guardrails beat clever prompts, every time.
Where it landed
| Before | After | |
|---|---|---|
| Total violations | 412 | 89 |
| Critical | 131 | 0 |
| Serious | 148 | 12 |
| Rules fully closed | β | 17 / 23 |
| My hands-on time | β | ~2.5 h |
The remaining 89 are almost entirely color-contrast (a design-token decision, not a code fix) and the 31 unresolved buttons that needed a product answer.
Lessons Learned
1. A violation count is not a task list.
412 sounds like 412 problems. It was 23 problems with a multiplier. The single best thing I did was group_by(rule) before doing anything else. If you're handing an agent a big pile of mechanical work, spend your first twenty minutes finding the axis that collapses it β the shape of the batch matters more than the wording of the prompt.
2. Agents are excellent at mechanical rules and dangerous at semantic ones.
link-name where the link wraps visible text? Perfect, 29 for 29. label on a form input where the label text has to describe a domain concept the agent has never seen? That's a product decision wearing a lint error's clothing. The tell is simple: if a human would need to ask someone what this control does, the agent will invent the answer instead of asking. Route those to a human queue up front.
3. Give it a failing check, not a description.
"Make this accessible" produced a 900-line unreviewable diff. "This node fails button-name; here is axe's failure summary; the check must pass afterward" produced small, verifiable diffs. An agent with an executable definition of done behaves like a different tool than one working from prose.
4. No ARIA beats bad ARIA β and agents don't believe this.
This is where I saw the most confident wrong output by a wide margin. ARIA is heavily represented in training data as the accessibility mechanism, so the agent reaches for it first, when the correct fix is usually to delete the <div> and use the native element. I had to put it in the prompt as a hard rule: native element first; ARIA only when no native element exists; never both. Even then it drifted, which is what the revert branch was for.
5. Zero violations is not the same as usable.
After the automated pass I spent forty minutes driving the dashboard with VoiceOver and keyboard only. Found six blockers that scored a clean 0 in automation: a modal that didn't trap focus, a toast that announced nothing, a table where every row read out the column headers again, and three "skip to content" links that skipped to the wrong place. Automated a11y tooling catches roughly the machine-checkable third of the problem. An agent that closes 100% of your automated findings has closed maybe a third of your actual accessibility debt. Say that out loud before someone puts "AI-accessible" on a slide.
What's Next
Two things. First, the scanner is going into CI as a budget rather than a gate β the build fails if the violation count goes up versus the base branch, which is the only version of this policy teams don't immediately start disabling. Second, I want to extend the same loop to keyboard navigation: focus order and focus traps are deterministic enough to assert on in Playwright, and they're where the genuinely painful bugs live. Same pattern β machine-readable findings, batch by failure kind, revert on regression.
The broader takeaway I keep re-learning: agents are strongest on tasks where an oracle already exists. Test suites, type checkers, linters, accessibility scanners. If you're staring at a big grind and there's a tool that can tell you objectively whether you're closer to done, you have most of an autonomous pipeline already β you're just missing forty lines of glue and a revert branch.
Wrap-up / CTA
If you're going to try this on your own codebase, do it in this order:
- Get findings into JSON before you open an agent. π§
- Group by rule, not by file.
- Give the agent an explicit "I don't know" escape hatch.
- Revert on regression, automatically.
- Then go drive it with a screen reader yourself, because the tool can't. β οΈ
I'm writing up more of these build logs as I go β follow me here on Dev.to if you want the keyboard-navigation follow-up when it lands. And if you've run an agent against a11y debt and hit failure modes I didn't, I genuinely want to hear about them in the comments β especially the ARIA horror stories. π¬
Top comments (0)