Accessibility
How to fix WCAG issues with Claude Code
Set accessibility rules in CLAUDE.md, give Claude Code one finding at a time, make it test its own change, and re-check the page before you merge.
Last updated
Claude Code works best on WCAG failures when you set it up to work on evidence, not guesses. Put short, testable accessibility rules in your project’s CLAUDE.md. Give Claude Code one finding at a time: the rule, the element, the measured value and the criterion.
Give it a check it must run before it says it is done, read the diff for the usual ARIA and focus shortcuts, and re-check the deployed page before you merge.
This guide shows the configuration for each of those steps, with three fixes from finding to check. It applies the AI coding agent workflow to Claude Code, Anthropic’s agentic coding tool.
What you need first
- Findings with evidence. An audit report, an axe-core run or a person’s notes, as long as each finding names the page, the element, what was measured and the WCAG criterion.
- A check that runs locally. This guide uses Playwright with
@axe-core/playwright. Any command that exits non-zero on a failure works. - A branch. Each fix goes in its own commit, so you can review and revert them one by one.
Every Claude Code feature below is taken from Anthropic’s Claude Code documentation, read on 1 November 2026 (Claude Code’s MCP page on 4 November 2026). Flags and file locations change between releases, so check the linked pages if something behaves differently.
If you were looking for how to use Claude Code itself with assistive technology, Anthropic documents Claude Code’s screen reader mode.
Put testable accessibility rules in CLAUDE.md
Claude Code reads CLAUDE.md at the start of every session. A project file lives at ./CLAUDE.md or ./.claude/CLAUDE.md and is shared with your team through version control. If you do not have one, /init generates a starting file from your codebase.
Anthropic’s documentation is clear about two things. CLAUDE.md is context, not enforced configuration, so instructions work best when they are “concrete enough to verify”. And shorter files are followed more reliably: it suggests staying under 200 lines. So write rules Claude can check itself against:
## Accessibility- Use native elements first: <button> for actions, <a href> for links, <label for> for form fields. Add ARIA only when no native element works.- Every icon-only button has an accessible name. Decorative icons get aria-hidden="true".- An aria-label on a control with visible text starts with that text.- Never remove a focus outline without a visible :focus-visible replacement.- Colours come from src/styles/tokens.css. Do not add hex values or change a token's value; if no token passes contrast, stop and ask.- Error messages are linked to their field with aria-describedby, and the field gets aria-invalid="true" while the error shows.- Do not change visible copy or editor-written alt text unless the task says so.- Before saying a task is done, run `npm run test:a11y` and paste the summary.If the rules only matter for your components, move them out of CLAUDE.md into a path-scoped rule. Files in .claude/rules/ load like CLAUDE.md, and a rule with paths frontmatter loads only when Claude works with matching files:
---paths: - "src/components/**/*.tsx"---# Accessibility rules for components- Interactive elements are <button>, <a href> or form controls, never a <div> with a click handler.- Every component story includes a keyboard-only interaction.If part of your team uses Cursor, rules like these work as Cursor project rules. Your design system can be written the same way, as values with a check, in a design manual Claude can check its UI changes against.
Give Claude a check it can run
A rule Claude can test is better than one it has to remember. This Playwright spec runs axe-core’s WCAG A and AA rules on the pages you list (set baseURL in playwright.config), including its one WCAG 2.2 rule, target-size (2.5.8), which runs only when you request the wcag22aa tag:
// tests/a11y.spec.tsimport { test, expect } from '@playwright/test';import AxeBuilder from '@axe-core/playwright';const paths = ['/', '/newsletter', '/checkout/address'];for (const path of paths) { test(`no axe violations on ${path}`, async ({ page }) => { await page.goto(path); const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) .analyze(); expect(results.violations).toEqual([]); });}Add "test:a11y": "playwright test tests/a11y.spec.ts" to your package.json scripts, then let Claude run it without a prompt each time. In .claude/settings.json:
{ "permissions": { "allow": [ "Bash(npm run test:a11y *)" ] }}A trailing space and asterisk, as in test:a11y *, also matches the bare command, so this allows npm run test:a11y with or without arguments, such as npm run test:a11y -- -g /newsletter to run one page.
For faster feedback, a hook can lint every file Claude edits. Hooks are shell commands Claude Code runs at fixed points; a PostToolUse hook with the matcher Edit|Write runs after each file edit, and when the command exits with code 2, its error output is shown to Claude. This one runs ESLint on the edited file; it checks accessibility if your ESLint config (ESLint 9 flat config) enables a JSX accessibility plugin such as eslint-plugin-jsx-a11y, which does not yet declare support for ESLint 10. Merge this hooks block into the same .claude/settings.json as the permissions block:
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-a11y.sh" } ] } ] }}#!/usr/bin/env bash# .claude/hooks/lint-a11y.sh — lint the edited file; exit 2 sends errors to ClaudeFILE=$(jq -r '.tool_input.file_path // empty')case "$FILE" in *.jsx|*.tsx) ;; *) exit 0 ;;esacnpx eslint --no-warn-ignored "$FILE" >&2 || exit 2Make the script executable with chmod +x .claude/hooks/lint-a11y.sh; it needs jq. The hook only sees edits made with Claude’s file tools. A file rewritten by a shell command does not trigger it, which is one more reason to keep the full check as the done-when step.
Know what this check proves. axe-core decides failures a rule can establish from the rendered page: missing names, low contrast, invalid ARIA. It cannot judge whether a label is meaningful or a focus order makes sense. A passing run means no rule found a failure on the pages and states it reached. We list which WCAG criteria an automated rule can check at all in a separate table.
The same checks can also run without Claude, in your editor, your tests and CI: we cover layering lint, component and end-to-end accessibility checks, with a CI job.
Hand over one finding at a time
One finding per task keeps every diff small enough to review. To avoid retyping the same instructions, save them as a project skill. A file at .claude/skills/fix-a11y/SKILL.md becomes the /fix-a11y command, and $ARGUMENTS receives whatever you type after it:
---name: fix-a11ydescription: Fix one accessibility finding with the smallest change and prove it with the a11y check.argument-hint: "[finding: rule, criterion, page, element, evidence]"disable-model-invocation: true---Fix exactly one accessibility finding:$ARGUMENTS1. Find the component that renders the element. If it is outside src/components or src/pages, stop and tell me where it is.2. Make the smallest change that removes the failure. Follow the Accessibility section of CLAUDE.md.3. Run `npm run test:a11y -- -g <page path>` and report the result.4. List every file you changed, and anything you were unsure about.Do not change copy, layout or unrelated code.Then run it with the finding pasted in:
/fix-a11y axe-core button-name (WCAG 4.1.2), /checkout/address at 390px,element: header button.menu-toggle, evidence: the button has no accessible namedisable-model-invocation: true keeps Claude from starting the skill on its own; you decide when a fix starts. For a finding that lives in a shared component, start in plan mode (press Shift+Tab until the status bar shows “plan mode on”, or put /plan before the prompt). Claude reads the code and proposes the change, and edits stay blocked until you approve the plan.
Three fixes, before and after
An icon-only button with no name
The finding: axe-core’s button-name rule, which axe-core classifies under 4.1.2 Name, Role, Value. A screen reader announces “button” and nothing else.
<button className="menu-toggle" onClick={toggleMenu}> <MenuIcon /></button><button className="menu-toggle" onClick={toggleMenu} aria-expanded={open} aria-controls="site-menu" aria-label="Menu"> <MenuIcon aria-hidden="true" focusable="false" /></button>The aria-label gives the button a name, and hiding the SVG stops it being read as well (this assumes your icon component passes those attributes on to the svg). aria-expanded tells screen reader users whether the menu is open; check that Claude wired it to real state rather than hard-coding "false". aria-controls must name the menu’s real id: with the menu open and no element with id="site-menu", axe-core’s aria-valid-attr-value rule fails. The rule re-checks the name. Whether “Menu” is the right word for your visitors is a person’s call.
Grey text that fails contrast
The finding: axe-core’s color-contrast rule, 1.4.3 Contrast (Minimum). The help text is #8a8a8a on white, a ratio of 3.45:1 against the 4.5:1 required for normal-size text.
.field-hint { color: #8a8a8a;}.field-hint { color: var(--color-text-muted);}The CLAUDE.md rule matters here. Without it, the fastest fix is a new hex value in the component, or a darker value for the token itself, which changes every page that uses it. With it, Claude either picks an existing token that passes or stops and asks. If no token passes, choosing a new colour is a design decision, not a code change.
An error message that is not tied to its field
The finding comes from a person, not a rule: on /checkout/address, the “Enter a postcode” error appears in red under the field, but nothing marks the field as in error, and a screen reader user who moves to the field hears nothing about it. The person flagged it under 1.3.1 Info and Relationships: the error is shown in text, which is what 3.3.1 Error Identification asks for, but tied to the field only visually. axe-core has no rule that checks whether an error message is connected to its field.
<label htmlFor="postcode">Postcode</label><input id="postcode" name="postcode" />{error && <p className="error">{error}</p>}<label htmlFor="postcode">Postcode</label><input id="postcode" name="postcode" aria-invalid={error ? true : undefined} aria-describedby={error ? 'postcode-error' : undefined}/>{error && <p id="postcode-error" className="error">{error}</p>}aria-describedby makes the screen reader read the error with the field. aria-invalid marks the field as in error, using ARIA21, one of the techniques W3C lists as sufficient for 3.3.1, though 3.3.1 itself does not require programmatic information. Because axe-core has no rule for this, the done-when step is a person’s: trigger the error, tab to the field with a screen reader on, and listen. Whether the message tells people how to fix the error is a judgment no check makes.
Review the diff before you accept it
Run /diff to see every change in your working tree, including Claude’s edits. Then read it against the shortcuts coding agents take with ARIA and focus: a placeholder or aria-label used as the only label, a role added without keyboard behaviour, a name that drops the visible text, a removed outline, or aria-hidden on something focusable.
A second pair of eyes helps. A subagent runs in its own context window, so it reviews the change without the conversation that produced it. Save this as .claude/agents/a11y-reviewer.md:
---name: a11y-reviewerdescription: Reviews accessibility fixes in the current diff. Use after an accessibility change, before commit.tools: Read, Grep, Glob, Bash---Run `git diff` and review the uncommitted accessibility changes. For each change, report:- ARIA that a native element could replace- roles without keyboard behaviour (Enter, Space, focus)- accessible names that do not start with the visible text- removed or invisible focus styles- aria-hidden on focusable content- new colour values or edited design tokens- any change outside the files the task namedDo not edit files. List findings with file and line.It has no Edit or Write tool, and its instructions say not to edit; it can still change files through Bash, so its shell commands go through the same permission rules as yours. Then do the one check no subagent can: put the mouse away and use the changed control with the keyboard.
When you accept the change, commit it on its own, with the finding in the message. Ask Claude to write it in this shape:
fix(a11y): name the menu toggle buttonaxe-core button-name, WCAG 4.1.2, /checkout/address at 390pxelement: header button.menu-togglecheck: npm run test:a11y -- -g /checkout/address passes locallyA commit that names its finding is easy to match against the next audit, and easy to revert on its own if the re-check on the deployed page finds that the fix broke something else.
Check the deployed page
Claude’s check ran on a dev server with test data. Your users get the deployed page, with real content, real styles and every screen size. After the change reaches a preview or staging environment, re-run the same check against it: the same rule, on the same page, at the same widths. If you have a Playwright baseURL, point it at the preview. Then check the other pages that use the component you changed.
Record the result honestly. If the rule no longer finds the failure, the failure is no longer found on what you tested. That is not proof that the fix works for every user.
Running fixes outside the interactive session
Claude Code also runs non-interactively with -p, which is useful for working through a list of findings from a script:
claude -p "$(cat findings/0042.md) Fix this one finding following CLAUDE.md, then run npm run test:a11y." \ --allowedTools "Read,Edit,Bash(npm run test:a11y *)"--allowedTools uses the same permission rules as the settings file, so the run can edit files and run the check without asking; in a non-interactive run with no permission mode set (the default for -p), any other action that needs approval is denied (reads and read-only commands such as ls need none). Keep one finding per run and one commit per finding, and review each diff as you would in the interactive session. Anthropic recommends --bare for CI and scripts so every machine gives the same result, but --bare skips CLAUDE.md, hooks and skills. Bare mode never reads your Claude subscription login, so with the Anthropic API it needs an ANTHROPIC_API_KEY (or an apiKeyHelper passed with --settings). If you use it, pass your rules in another way, for example with --append-system-prompt-file.
Reading findings over MCP
Instead of having findings pasted in, Claude Code can read them from an audit tool’s remote MCP server. If you audit with Your UX Agent, ours is at https://youruxagent.com/api/mcp. Claude Code adds a remote server over HTTP with one command:
claude mcp add --transport http your-ux-agent https://youruxagent.com/api/mcpThen run /mcp inside Claude Code and follow the sign-in in your browser, where you sign in to your account and approve the access Claude Code asks for. Added this way, it asks for read and write access, and the approval screen says so. Read access covers your whole account: Claude Code can list your projects, read a report’s findings and each finding’s status, and fetch the evidence for one finding when you ask for it. Write access also lets it record a finding’s status, create projects, and quote and then run audits.
To keep Claude Code read-only, add the server this way instead, pinning the read scope. If you already added it, run claude mcp remove your-ux-agent and revoke the old connection under Settings › MCP in Your UX Agent first:
claude mcp add-json your-ux-agent '{"type":"http","url":"https://youruxagent.com/api/mcp","oauth":{"scopes":"mcp:read"}}'With write access, marking a finding applied once its fix is deployed marks its task “fix applied”. A later audit, started after that, which runs the same check on that page and no longer finds the violation, resolves the task. If the project has a schedule, its next scheduled audit can do that when it covers the page, and so can a full audit of the project that covers it, which you start in the app or by having Claude Code quote and run it. So can an audit of one URL that you type in or have Claude Code run, but only when it is the same address as one of the site addresses you added to the project, and only for that page. Any other page audited this way, even one a full audit found, is not linked to the project’s tasks, so it resolves none of them. Add --scope project to store the server in a .mcp.json file you can commit for your team. Each person still signs in with their own account, and Claude Code asks for approval before it uses a project’s servers in an interactive session. Sign in once interactively, with /mcp or claude mcp login your-ux-agent in your shell, before you use the server in a claude -p run, and add the tools that run may call to --allowedTools, for example mcp__your-ux-agent__get_report_findings.
The rest of this guide applies unchanged: one finding per task, a check Claude must run, your review, and a re-check on the deployed page. We never change your repository; Claude Code makes the edits in your codebase.
What to keep human
- Alt text and other content from your CMS. Claude can fix a template’s missing
altattribute. It cannot know what an editor’s photo is for, and it should not rewrite content editors own. - Brand colours. When no existing colour passes contrast, someone who owns the design chooses the new one.
- Focus order and reading order. Whether the order follows the task is a judgment about the page, not the code.
- Wording. Labels, error messages and link text that make sense to your visitors.
- What a screen reader actually says. Test custom widgets with the screen readers and browsers you support.
- The decision to merge. Claude’s passing check is evidence for your review, not a replacement for it.