A yellow tactile paving panel set into a grey concrete pavement

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.

CLAUDE.md — the accessibility section
## 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.

tests/a11y.spec.ts — @axe-core/playwright over the routes that matter
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.

.claude/hooks/guard-a11y.sh — matched on Edit|Write in .claude/settings.json
#!/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 0

Keep 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/.

.claude/commands/a11y-pass.md
---
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.

Written by

Karol

Senior engineer and systems architect behind Tall Karol. Everything published here is grounded in real client work — no roundups, no tools that haven't run in production.

Why Tall KarolWork with Tall Karol

Related notes

More on web development

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