Claude Code for Accessibility: Auditing and Fixing A11y Issues
Why Accessibility Keeps Getting Deprioritized (And Why That's a Mistake)
Every team says accessibility matters. Almost no team ships it consistently. It gets bumped for the next sprint's feature work, treated as a "nice to have" that a design system will eventually solve, or bolted on right before a compliance audit in the form of a rushed alt tag sweep. The result is predictable: screen reader users hit forms they can't submit, keyboard users get trapped in modals with no escape route, and color-contrast failures quietly exclude anyone with low vision.
The reason accessibility gets skipped isn't that engineers don't care. It's that fixing it properly is tedious, requires domain knowledge most frontend developers never studied in depth, and doesn't show up as a visual bug in a Figma comparison. You can't "eyeball" whether a custom dropdown announces its expanded state to assistive technology. You have to know the ARIA spec, test with actual screen readers, and understand how the DOM tree maps to the accessibility tree.
This is exactly the kind of work where an agentic coding tool changes the economics. Claude Code can read your component tree, cross-reference it against WCAG success criteria, run automated audits, and produce targeted patches — not generic "add alt text everywhere" advice, but specific fixes tied to the actual markup in your repo. This article walks through a practical workflow: how to point Claude Code at a real codebase, what prompts produce useful audits versus noise, and concrete before/after code for the accessibility bugs that show up in nearly every production app — broken focus management, missing form labels, non-semantic custom widgets, and color contrast violations.
If you're building the kind of agentic development skills this implies — treating an AI coding assistant as a specialized reviewer rather than just an autocomplete engine — that's precisely the muscle we train in the Claude Code Tutorial for Beginners course, which we'll circle back to at the end.
Setting Up Claude Code for an Accessibility Pass
Before asking Claude Code to fix anything, give it context it can actually reason from. Accessibility bugs are contextual — the same <div onClick> is fine in one place and a violation in another, depending on whether it's meant to behave like a button. A blind grep-and-replace pass makes things worse, not better.
Start a session at the root of your project and orient Claude Code with a scoped instruction rather than a vague one:
claudeThen, inside the session, don't just say "check accessibility." Give it a target and a standard to check against:
Audit the components in src/components/forms and src/components/modals
against WCAG 2.2 AA. For each file, list:
1. The specific success criterion violated (e.g. 4.1.2 Name, Role, Value)
2. The exact line(s) responsible
3. A minimal code fix
Do not rewrite styling or unrelated logic. Only touch what's needed for
the violation.This kind of prompt matters more than it looks. "Audit for accessibility" invites Claude Code to hallucinate generic advice about alt text and color contrast even in files that don't have images or custom colors. Naming the standard (WCAG 2.2 AA), scoping the directories, and demanding line-level citations forces it to actually read the code rather than pattern-match to accessibility folklore.
It also helps to feed Claude Code your project's existing conventions. If you have a CLAUDE.md file (the project-level memory file Claude Code reads automatically), add a short accessibility section:
## Accessibility conventions
- All interactive custom components must have a matching ARIA role.
- Never suppress focus outlines without providing a visible alternative.
- Form inputs must use `<label htmlFor>` or `aria-labelledby`, not
placeholder-only labeling.
- Test with keyboard-only navigation before marking a11y work done.Once that file exists, every future session picks up these rules automatically, so you're not re-explaining your standards every time.
Running an Automated Audit Alongside Claude Code
Claude Code is strong at reading code and reasoning about structure, but it isn't a replacement for tools that actually render the DOM and inspect the accessibility tree at runtime. The most reliable workflow pairs Claude Code with axe-core or a similar automated checker, then hands the tool's output back to Claude Code for triage and fixes.
A typical setup, if the project doesn't already have this:
npm install --save-dev @axe-core/playwright playwrightThen a small test file that exercises key pages:
// tests/a11y.spec.js
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test('homepage has no automatic a11y violations', async ({ page }) => {
await page.goto('http://localhost:3000');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('checkout form has no automatic a11y violations', async ({ page }) => {
await page.goto('http://localhost:3000/checkout');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});Run it, capture the JSON violation output, and hand it directly to Claude Code:
Here is the axe-core violation report from our Playwright a11y test run
(pasted below). Map each violation to the source file responsible, explain
why it's failing, and propose a fix. Group by severity (critical, serious,
moderate, minor) and start with critical.This division of labor is important: axe-core tells you *what* failed against the accessibility tree at runtime (which is ground truth), and Claude Code tells you *why* it failed in your source and *how* to fix it without breaking anything else. Neither tool alone gets you there efficiently — axe-core output is a flat list of technical violations with no awareness of your component architecture, and Claude Code without runtime data will miss issues that only manifest after JavaScript hydration, like a dynamically injected modal that never receives focus.
Fixing Missing and Broken ARIA: A Concrete Example
The single most common accessibility bug in component libraries is a custom-built interactive element — a dropdown, a tab list, a toggle — that looks right visually but is invisible to a screen reader because it's built from generic divs with no semantic role.
Here's a realistic broken pattern, the kind Claude Code will actually catch when scoped correctly, and the kind that regularly ships to production:
// Before: visually a tab list, semantically nothing
function TabList({ tabs, activeTab, onSelect }) {
return (
<div className="tab-list">
{tabs.map((tab) => (
<div
key={tab.id}
className={tab.id === activeTab ? 'tab active' : 'tab'}
onClick={() => onSelect(tab.id)}
>
{tab.label}
</div>
))}
</div>
);
}This fails several WCAG criteria at once: no keyboard access (divs aren't focusable by default), no role information (a screen reader announces nothing useful when focus lands here), and no indication of which tab is selected. Asking Claude Code to fix "this specific tab list component, following the WAI-ARIA Authoring Practices tabs pattern" produces something like:
// After: semantic roles, keyboard support, state exposed to AT
function TabList({ tabs, activeTab, onSelect }) {
const handleKeyDown = (event, index) => {
if (event.key === 'ArrowRight') {
const next = tabs[(index + 1) % tabs.length];
onSelect(next.id);
document.getElementById(`tab-${next.id}`)?.focus();
}
if (event.key === 'ArrowLeft') {
const prev = tabs[(index - 1 + tabs.length) % tabs.length];
onSelect(prev.id);
document.getElementById(`tab-${prev.id}`)?.focus();
}
};
return (
<div className="tab-list" role="tablist" aria-label="Course sections">
{tabs.map((tab, index) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
role="tab"
type="button"
aria-selected={tab.id === activeTab}
aria-controls={`panel-${tab.id}`}
tabIndex={tab.id === activeTab ? 0 : -1}
className={tab.id === activeTab ? 'tab active' : 'tab'}
onClick={() => onSelect(tab.id)}
onKeyDown={(event) => handleKeyDown(event, index)}
>
{tab.label}
</button>
))}
</div>
);
}Note what changed and why each change matters: swapping div for button restores native keyboard focusability and default Enter/Space activation for free. role="tablist" and role="tab" tell assistive technology what this widget actually is, so a screen reader announces "Course sections, tab list" instead of silence. aria-selected exposes which tab is active — critical, because the visual active class means nothing to a screen reader. aria-controls links each tab to the panel it reveals. And the roving tabIndex pattern (0 for the active tab, -1 for the rest, with arrow-key handling) matches the expected keyboard behavior for tabs, where Tab moves focus into and out of the whole widget, and arrow keys move between tabs within it.
This is the kind of fix that's hard to get right from memory but straightforward once you tell Claude Code exactly which ARIA pattern to follow. A vague "make this accessible" prompt often produces role="tab" without the keyboard handling, which technically satisfies a linter but fails a real screen reader test — so always ask for the specific interaction pattern (tabs, combobox, dialog, disclosure) by name, since these are documented reference patterns Claude Code can implement correctly rather than improvise.
Fixing Focus Management in Modals and Dialogs
Modals are the other perennial offender. A modal that traps focus incorrectly, doesn't restore focus on close, or doesn't announce itself to screen readers is a WCAG 2.4.3 (Focus Order) and 4.1.2 (Name, Role, Value) violation rolled into one, and it's also one of the most disorienting experiences for a keyboard-only user — tabbing forward and suddenly landing back in the page body behind an open dialog.
A broken but common implementation:
// Before: no focus trap, no restoration, no role
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div className="modal-content">
<span className="close-icon" onClick={onClose}>×</span>
{children}
</div>
</div>
);
}Prompting Claude Code with "fix focus management in this modal per the WAI-ARIA dialog pattern — trap focus while open, restore it on close, and make the close control keyboard accessible" typically yields:
// After: focus trapped, restored on close, properly announced
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, children, titleId }) {
const dialogRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
previousFocusRef.current = document.activeElement;
const dialogNode = dialogRef.current;
const focusable = dialogNode.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable[0]?.focus();
function handleKeyDown(event) {
if (event.key === 'Escape') {
onClose();
}
if (event.key === 'Tab' && focusable.length > 0) {
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
previousFocusRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div
className="modal-content"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
ref={dialogRef}
>
<button
type="button"
className="close-icon"
onClick={onClose}
aria-label="Close dialog"
>
×
</button>
{children}
</div>
</div>
);
}Four fixes are doing the real work here. role="dialog" and aria-modal="true" tell assistive technology this is a modal region, so screen readers know to treat content outside it as hidden. aria-labelledby gives the dialog an accessible name by pointing to whatever heading element you render inside (you'd pass its id in as titleId). The focus trap loop on Tab/Shift+Tab keeps keyboard users cycling within the dialog instead of escaping into the page behind it. And storing previousFocusRef and restoring it on cleanup means that when a user closes the modal, focus goes back to the button that opened it — not to the top of the document, which is jarring and makes users lose their place.
The <span className="close-icon" onClick={onClose}> in the original is also worth calling out on its own: a clickable span with no keyboard handler is completely invisible to keyboard-only users, who have no way to trigger it at all. Swapping it for a real <button> with aria-label="Close dialog" fixes both problems in one line — free keyboard support and an accessible name for the icon-only control.
Form Labels, Error States, and Live Regions
Forms are where accessibility failures translate most directly into lost conversions and support tickets, because a form a screen reader user can't complete is a form they abandon. The most common failure isn't a total absence of labels — it's labels that are visually present but not programmatically associated with their input, usually because someone used a placeholder or a loosely positioned <label> without a for/id pair.
// Before: label exists visually, not programmatically
<div className="form-field">
<label>Email address</label>
<input type="email" value={email} onChange={handleEmailChange} />
{emailError && <span className="error-text">{emailError}</span>}
</div>A screen reader user tabbing into this input hears only "edit text" — no indication of what the field is for, and no announcement of the error message when validation fails. The fix Claude Code should produce when asked to "associate labels correctly and make error messages announce to screen readers":
// After: label linked via htmlFor/id, error linked via aria-describedby,
// error region is a live region so screen readers announce it on change
<div className="form-field">
<label htmlFor="email-input">Email address</label>
<input
id="email-input"
type="email"
value={email}
onChange={handleEmailChange}
aria-invalid={Boolean(emailError)}
aria-describedby={emailError ? 'email-error' : undefined}
/>
{emailError && (
<span id="email-error" className="error-text" role="alert">
{emailError}
</span>
)}
</div>Three separate mechanisms are stacked here, and it's worth understanding why all three are needed rather than just one. htmlFor/id gives the input a proper accessible name, so a screen reader announces "Email address, edit text" on focus. aria-invalid and aria-describedby together tell assistive technology that the field is currently invalid and point to the specific error text explaining why — this is what makes a screen reader announce "Email address, edit text, invalid data, Please enter a valid email" instead of just the label. And role="alert" on the error span makes it a live region, so if the error appears *after* the user has already tabbed past the field (for instance, during async validation on blur), it gets announced immediately rather than silently appearing where no one will encounter it.
This is a good spot to flag a subtlety Claude Code sometimes gets wrong on the first pass: it's tempting to slap role="alert" on every dynamic message in an app, but overusing live regions creates a noisy, exhausting experience where screen readers interrupt constantly. Ask specifically for role="alert" (assertive, interrupts immediately) only on validation errors and critical status changes, and aria-live="polite" for lower-priority updates like "3 items in cart" — and say so explicitly in your prompt, because the distinction matters more than either option in isolation.
Color Contrast and Non-Text Indicators
Color contrast failures are the easiest category to detect automatically and the easiest to fix mechanically, which makes them a good task to hand to Claude Code with minimal back-and-forth — but only once you've fed it the actual computed values, not just component names.
Pull the contrast failures from your axe-core report (they come back with the exact foreground/background hex values and the ratio computed) and paste them in:
axe-core reports this contrast failure:
Element: .btn-secondary
Foreground: #999999, Background: #f2f2f2
Contrast ratio: 2.31:1 (needs 4.5:1 for normal text, WCAG 1.4.3)
Find where .btn-secondary is defined and suggest a foreground color that
hits at least 4.5:1 against #f2f2f2, staying visually close to the
existing gray so it doesn't clash with the rest of the button system.Claude Code can compute this correctly because contrast ratio is a defined formula, not a subjective call — it'll typically suggest something like darkening #999999 to #5c5c5c or similar, which at that same background hits roughly 4.6:1, and it can show the math rather than asserting it. The fix itself is a one-line CSS change:
.btn-secondary {
color: #5c5c5c; /* was #999999 — 2.31:1, now ~4.6:1 against #f2f2f2 */
background-color: #f2f2f2;
}The other half of this category is relying on color alone to convey information — a common pattern in status indicators, form validation, and charts. A red border on an invalid input is invisible to a colorblind user or meaningless to a screen reader user entirely. The fix is always to pair color with a second signal:
// Before: color is the only signal
<input className={hasError ? 'input-error' : 'input'} />
// After: icon + text + aria-invalid pair with the color change
<div className="input-wrapper">
<input
className={hasError ? 'input-error' : 'input'}
aria-invalid={hasError}
/>
{hasError && (
<span className="error-icon" aria-hidden="true">⚠</span>
)}
</div>The aria-hidden="true" on the icon matters here too — the icon is purely decorative reinforcement for sighted users; the actual error information is already carried by the text message and aria-invalid, so exposing the icon glyph itself to screen readers would just add redundant noise ("warning sign" spoken right before the actual error text).
Building an Accessibility Regression Suite With Claude Code
A one-time audit fixes today's bugs. Without a regression check, the same class of bug reappears the next time someone ships a new dropdown or modal. Have Claude Code help you turn the fixes above into a standing test suite rather than a one-off cleanup.
A practical ask:
Take the Playwright + axe-core setup in tests/a11y.spec.js and extend it
to run against every route in src/routes.js. Add a CI step that fails the
build on any "critical" or "serious" axe violation, but only warns
(doesn't fail) on "minor" ones so we can migrate incrementally.This produces something close to:
// tests/a11y.spec.js
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
const routes = require('../src/routes.js');
for (const route of routes) {
test(`a11y: ${route.path}`, async ({ page }) => {
await page.goto(`http://localhost:3000${route.path}`);
const results = await new AxeBuilder({ page }).analyze();
const blocking = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious'
);
const advisory = results.violations.filter(
(v) => v.impact === 'moderate' || v.impact === 'minor'
);
if (advisory.length > 0) {
console.warn(`Non-blocking a11y issues on ${route.path}:`, advisory);
}
expect(blocking).toEqual([]);
});
}Wiring this into CI means every future PR that reintroduces an unlabeled input or a focus trap gets caught before merge, not during a quarterly audit. This is also where the earlier CLAUDE.md conventions pay off again — when Claude Code generates new components in later sessions, it already knows your project fails CI on missing labels, so it tends to produce accessible markup by default rather than needing a correction pass.
Where This Fits Into a Broader Development Workflow
None of this replaces manual testing with a real screen reader (VoiceOver on macOS, NVDA on Windows) or input from users who rely on assistive technology daily — automated tools like axe-core catch roughly a third of accessibility issues by design, because things like "does this error message actually make sense when read aloud" require human judgment. What Claude Code changes is the cost of the mechanical half of the work: finding every unlabeled input across a fifty-component codebase, applying a consistent ARIA pattern across every dropdown, or rewriting focus-trap logic correctly on the first attempt instead of the third.
The workflow that works in practice is a loop, not a one-time sweep: run an automated audit, hand the output to Claude Code with a scoped and specific prompt, review the diff line by line (accessibility fixes are exactly the kind of change where a plausible-looking but subtly wrong aria-* attribute is worse than none at all), verify with a real screen reader on the pages that matter most, and then lock in a regression test so the fix survives the next refactor. Treating an AI coding assistant this way — as a fast, literal-minded collaborator that needs precise instructions and careful review rather than an oracle you trust blindly — is the core skill this entire style of tool rewards.
If this workflow is new to you, or you want a structured path through prompting patterns, project memory files, and multi-file refactors like the ones covered here, that's exactly what we walk through step by step in the Claude Code Tutorial for Beginners course on TeachYou.ai.
AI CodingShip full-stack AI apps at conversation speed — specs, agents, deploys, all from the terminal.
Claude CodeGo from zero to confident with Claude Code, the terminal agent that reads, edits, runs, and verifies real code.
Related reading