
Guide
Claude Code rules for WCAG testing
Put the WCAG rules an agent can act on in CLAUDE.md, gate them with axe-core in Playwright, and keep a human checklist for the half no tool can test.
Published August 9, 2026
Write the WCAG rules the agent can act on into CLAUDE.md, then make axe-core run against real pages in a build gate that exits non-zero, and keep a short human checklist for the half of WCAG no tool can test. Accessibility fails in all three ways, so it needs all three layers.
Why an agent regresses accessibility faster than performance
Because nothing moves when it does. A performance regression shows up as a number in the route table; an accessibility regression looks like working code. A <div onClick> renders and clicks fine. outline: none makes the design cleaner in every screenshot the agent can take. An aria-label added to silence a linter reads as diligence. The page keeps building, the screenshots keep looking right, and the site quietly stops working for anyone navigating it by keyboard.
So the ruleset has a job the performance one doesn't: it has to supply the feedback the agent can't get from looking. That means rules phrased as decision procedures — this element, that attribute, this threshold — and it means a gate that runs the checks a browser can run, because “it looks fine” is the failure mode, not the test.
Layer one: rules specific enough to follow while editing
The rules that work name an element, an attribute, or a number. Every prohibition states the replacement, because a model that is told only what not to do improvises — usually by reaching for ARIA, which is how a simple button becomes a custom widget that announces nothing.
## Accessibility (WCAG 2.2 AA — non-negotiable)
- **Native element first.** `<button>` for actions, `<a href>` for
navigation, `<label for>` for every input, `<table>` for tabular data.
A `<div>` with `onClick` and `role="button"` is a rewrite, not a fix:
it has to reimplement focus, Enter, Space, and disabled state.
- **ARIA only when no native element does the job**, and never to patch a
wrong one. `aria-label` on an icon-only button: required. On a button
that already has visible text: noise that overrides the label a user
reads aloud.
- **Focus is always visible.** `outline: none` is allowed only in the same
change that ships a replacement indicator at 3:1 against both adjacent
surfaces (1.4.11). Never remove a focus ring to fix a design review.
- **No positive `tabIndex`.** `0` to make a custom control focusable, `-1`
for a programmatic focus target, nothing else (2.4.3).
- **Every interactive target is 24x24 CSS px or has 24px of clear spacing**
(2.5.8). Icon buttons get padding, not a smaller icon.
- **One `<h1>` per page, no skipped levels**, headings describe the section
rather than decorate it (1.3.1, 2.4.6).
- **Images:** meaningful ones get alt text saying what the image
communicates in this context; decorative ones get `alt=""`. A missing
`alt` attribute is never correct.
- **State changes are announced.** Field errors tie to the input with
`aria-describedby`; async results land in a live region (4.1.3). A
toast the screen reader never mentions did not happen.
- **Colour is never the only carrier of meaning** (1.4.1) — pair it with
text, an icon, or a shape.
- **Contrast is computed, never eyeballed**, compositing alpha text over
its real background: 4.5:1 body text, 3:1 for large text and for UI
boundaries. This palette is already solved — see the token rules above;
`tk-linen/80` on teal composites to 4.07:1 and fails, `/90` passes.
- **Never tween opacity on text.** Contrast is computed from whatever
opacity the audit samples, so a crossfade fails colour-contrast
nondeterministically. Animate `transform` only.
- **All motion respects `prefers-reduced-motion`**, including CSS
animation in `globals.css`.
- **Never silence a check to make it pass.** No `disableRules`, no
`eslint-disable jsx-a11y`, no dropping a route from the axe run. If you
believe a violation is a false positive, leave it failing and say so.The last rule is the one that earns its place. Every other rule describes how to write the markup; that one describes what to do when the check disagrees with you, which is the moment an agent otherwise optimises for the green result instead of the outcome. Failure text is a prompt, and so is a ruleset — write both as instructions to someone about to take the shortcut.
Layer two: the gate — axe in a real browser, exiting non-zero
Rules are context and context is probabilistic. The gate is a Playwright run that loads each route in a real Chromium, runs axe-core against the rendered DOM, and fails the process on any violation. That exit code is the part that survives a long refactor, because it works the same in the agent's session, your terminal, and CI.
import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"
// Every route a user can reach without an account. Add the route the day
// it ships; an unlisted route is an untested route.
const ROUTES = ["/", "/services/web-development", "/knowledge-base", "/contact"]
for (const route of ROUTES) {
test(`axe ${route}`, async ({ page }) => {
await page.goto(route)
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
.analyze()
// Assert on the readable list, not a count: a failing test that names
// the rule and the selector is a fix; "expected 3 to be 0" is a puzzle.
expect(violations.flatMap((v) =>
v.nodes.map((n) => `${v.id} [${v.impact}] ${n.target.join(" ")} — ${v.help}`)
)).toEqual([])
})
}
// 1.4.10 Reflow: 400% zoom on a 1280px screen is a 320px viewport.
test("no horizontal scroll at 320px", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 })
await page.goto("/")
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
)
expect(overflow, "something is wider than the viewport at 320px").toBe(0)
})Two things about what this buys you. Deque's own figure for axe-core is a little over half of WCAG issues detectable automatically, and that is the optimistic end of the published numbers — treat a clean run as the floor, never the result. And Lighthouse's accessibility score is a subset of these same rules on a single viewport in a single state, which is why a modal with no focus trap sits comfortably behind a score of 100. Run axe directly and the score becomes a by-product.
Layer three: hooks, for the edits that must never land
A gate catches the regression after the code exists. A PreToolUse hook catches the two or three patterns that have no legitimate exception before the edit is written. Hooks live in .claude/settings.json, receive the tool call as JSON on stdin, and block it by exiting 2 — stderr goes back to the model as the reason, so it reads as an instruction rather than a crash.
#!/usr/bin/env bash
payload="$(cat)"
file="$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty')"
content="$(printf '%s' "$payload" | jq -r '.tool_input.new_string // .tool_input.content // empty')"
case "$file" in
*.tsx|*.jsx|*.css)
if printf '%s' "$content" | grep -qE 'outline:[[:space:]]*(none|0)' &&
! printf '%s' "$content" | grep -qE 'focus-visible|outline-offset|box-shadow'; then
echo "outline: none with no replacement focus indicator (WCAG 2.4.7)." >&2
echo "Style :focus-visible instead, at 3:1 against both surfaces." >&2
exit 2
fi
if printf '%s' "$content" | grep -qE 'tabIndex=\{?"?[1-9]'; then
echo "Positive tabIndex breaks focus order (WCAG 2.4.3)." >&2
echo "Use 0 to make a control focusable, -1 for a focus target." >&2
exit 2
fi
if printf '%s' "$content" | grep -qE 'eslint-disable.*jsx-a11y|disableRules'; then
echo "Silencing an accessibility check is not a fix. Leave it failing." >&2
exit 2
fi
;;
esac
exit 0Keep this layer to a handful of patterns. Every hook is latency on every matching tool call, and a slow session is a session where someone turns the hooks off. Anything with a legitimate exception belongs in the ruleset; anything measurable belongs in the gate.
What no rule and no gate can check
Everything to do with meaning. Whether the alt text says the right thing rather than merely existing, whether reading order matches visual order, whether an error message tells you how to recover, whether a control announces something a person would understand out loud. Automated tooling cannot judge any of it, and pretending otherwise is how a site passes every check and still fails its users.
The move that makes this stick is to give the manual pass a command, so the machine-checkable half runs itself and hands you a short list rather than a vague obligation. Custom slash commands are markdown files in .claude/commands/.
---
description: Run the accessibility checks a machine can run, then print my checklist
argument-hint: [route, defaults to /]
allowed-tools: Bash(npm run test:a11y), mcp__playwright__*
---
Run `npm run test:a11y` and report every violation verbatim — rule id,
impact, and selector. Do not summarise to a count and do not fix anything
by adding ARIA or disabling a rule.
Then, with the browser MCP on $ARGUMENTS (default `/`):
1. Tab from the top through every interactive element. Report the first
element with no visible focus indicator, any element that takes focus
but does nothing, and any point where focus order jumps out of reading
order.
2. Emulate `prefers-reduced-motion: reduce` and report anything still
moving.
3. Set the viewport to 320x800 and report any horizontal overflow.
Finish by printing this checklist for me. Never tick a box yourself:
- [ ] VoiceOver over the changed area: does every control announce a name,
a role, and its state?
- [ ] Read the alt text aloud in place of the image — does the sentence
still carry the same information?
- [ ] Disable CSS: is the reading order still the order of the content?
- [ ] Trigger every error state: is the message specific, and does focus
or an announcement take you to it?
- [ ] Is anything conveyed by colour alone?Where this sits next to the performance rules
The two rulesets overlap more than they look like they should, and the overlap is where the interesting failures live. The contrast rule on this site exists because of an animation decision: tweening opacity on text makes a colour-contrast audit fail at random, depending on which frame it samples — a performance-shaped change producing an accessibility failure that reproduces one run in four. Rules that live in separate files still have to be read together.
The same layering applies to the speed budget on this site, in the Core Web Vitals ruleset. If your editor is Cursor rather than Claude Code, the rule content transfers directly but the enforcement points move — that version is here.
Automated checks catch perhaps half of what WCAG asks for. The rest is judgement, and it belongs in the build — accessibility is in scope from the start on every site I take on.
Questions
Isn't a Lighthouse accessibility score of 100 enough?
No. Lighthouse runs a subset of axe-core rules on one viewport in one state, so it scores the page it can see, not the page a keyboard or screen reader user gets. A modal with no focus trap, a form whose errors are never announced, and a flow that only works with a mouse can all sit behind a green 100.
Can the agent fix the violations axe reports?
Usually, and that's the risk. The fast path to a green axe run is an aria-label on everything and a rule disabled where the label didn't help, which reports as fixed while making the page worse for the people the rule exists for. Rule one of the ruleset is that silencing a check is never a fix.
Where does the human actually have to be in the loop?
On meaning. Whether alt text says the right thing, whether reading order matches visual order, whether an error message is useful, and whether a screen reader announcement makes sense out loud — none of that is machine-checkable. Automate everything else so the human pass is short enough to actually happen.
Related notes
- Cursor rules for WCAG testing
- Never tween opacity on animated text
- Claude Code rules for Core Web Vitals
More on web development
- Claude Code rules for Elementor sites
- Animating hero text without layout shift
- next/font: why display optional beats swap
- GA4 without gtag: a first-party collector
Related service: Web Development
Want this kind of engineering on your project?
Tall Karol takes on fractional and project-based engagements for startups and agencies.
Book a working session