Use when making a web UI conform to WCAG 2.2 Level AA — axe-core or Lighthouse a11y violations, keyboard operability, focus management, ARIA roles/names/live regions, contrast, tap-target size. NOT palette or visual intent (that is `design`), NOT test-runner setup (that is `testing-web`), NOT LCP/page-speed (that is `performance`).
npx skills add https://github.com/ericrisco/rsc-harness --skill accessibility
*The bar is conformance to WCAG 2.2 Level AA. "Looks fine to me" is not a measurement. Fix the semantics, scan what a machine can scan, then walk the part it can't.*
Run these in order. Skipping a step front-loads rework.
<button> ships focus, keyboard, and role for free. Most "a11y bugs" are a <div> doing a button's job.Decision rule: never ship on a green axe run alone. A clean automated scan means "no machine-detectable failures," not "accessible." Treat it as necessary, never sufficient.
Legal stakes are real: the EU European Accessibility Act became enforceable 2025-06-28 for many consumer products and services, on top of EN 301 549 / ADA. AA is the line.
The first rule of ARIA is: don't use ARIA. If a native element gives you the semantics and behavior, use it. Every role you add is behavior you now owe by hand — focus, keyboard, state.
<!-- Bad: zero keyboard, no role, no focus, no Enter/Space -->
<div class="btn" onclick="save()">Save</div>
<!-- Good: focusable, Enter/Space fire it, announced as "Save, button" -->
<button type="button" onclick="save()">Save</button>
| You want… | Use native… | Not… |
| ---------------------- | -------------------------- | ----------------------------- |
| A click action | <button type="button"> | <div role="button" onClick> |
| Navigation | <a href="…"> | <span onClick> + JS routing |
| Show/hide section | <details><summary> | hand-rolled aria-expanded |
| Form field | <input>/<select> | contenteditable div |
| Modal | <dialog> + showModal() | a div with role="dialog" |
Reach for ARIA only when no native element fits (tabs, comboboxes, toasts) — and then copy a vetted pattern (→ references/aria-patterns.md).
<h1> per page. Headings describe structure; never skip a level (<h2> then <h4>) to get a font size — that's a CSS job.<header> <nav> <main> <footer>. Exactly one <main>. Screen-reader users jump by landmark; a wall of <div> has no map.<a href="#main" class="sr-only-focusable">Skip to content</a>.aria-labelledby → aria-label → associated <label> / element text → title. Don't stack them hoping one sticks; pick one source.<!-- Bad: announced as just "button" -->
<button><svg aria-hidden="true">…</svg></button>
<!-- Good: announced as "Close dialog, button" -->
<button aria-label="Close dialog"><svg aria-hidden="true">…</svg></button>
A placeholder is not a label — it vanishes on input and many SRs ignore it. Use a real <label for>.
Everything a mouse can do, a keyboard must do.
tabindex.tabindex. Only 0 (in natural order) or -1 (focusable by script, skipped by Tab). A positive value hijacks the whole page's order and breaks the next dev's mental model.Overlays (dialogs, menus, drawers) need focus management — three obligations:
Composite widgets (menus, tabs, grids) use roving tabindex: one element is tabindex="0", the rest -1, arrow keys move the 0. Full keyboard tables per pattern — modal, disclosure, tabs, combobox, menu, toast → references/aria-patterns.md.
/* Bad: kills the focus ring with nothing in its place */
:focus { outline: none; }
/* Good: ring only for keyboard users, not mouse clicks */
:focus-visible { outline: 3px solid; outline-offset: 2px; }
WCAG 2.2 (W3C Recommendation, 2023-10-05) adds 9 success criteria and removes 4.1.1 Parsing. The six that matter at Level AA — know the numbers:
Measured ratios, AA minimums:
Never encode meaning in color alone (1.4.1). A red border on an invalid field is invisible to many users — pair it with text and an icon.
<!-- Bad: only color signals the error -->
<input class="border-red-500" aria-invalid="true">
<!-- Good: text + icon + programmatic association -->
<input aria-invalid="true" aria-describedby="email-err">
<p id="email-err">⚠ Enter a valid email address.</p>
Note: jsdom can't compute contrast (no real layout/paint), so jest-axe disables the rule. Verify contrast in a real browser (Playwright / Lighthouse) or by hand.
Mental model: Name, Role, Value. Every custom control needs an accessible *name*, the right *role*, and current *state/value* — and you must keep state in sync.
aria-expanded on a disclosure trigger, aria-controls pointing at what it toggles, aria-selected / aria-current for the active item. Toggle them in the same handler that changes the visual state.aria-live="polite" — wait for a pause (status, "Saved", search-result counts). Default choice.aria-live="assertive" — interrupt now (form submit error, session-expiry). Use sparingly.| Technique | Visual | Screen reader | Use for |
| ------------------ | ------ | ------------- | ----------------------------------------- |
| display:none | gone | gone | truly removed content |
| aria-hidden=true | shown | hidden | decorative visuals — never on a focusable element |
| .sr-only class | hidden | read | labels/skip links for SR users only |
<!-- Bad: focusable AND hidden from SR = a keyboard trap nobody can hear -->
<button aria-hidden="true">Menu</button>
<!-- Good: decorative icon hidden, the button keeps its name -->
<button aria-label="Menu"><svg aria-hidden="true">…</svg></button>
Three layers — each catches what the cheaper one can't.
Lint (static, JSX only) — eslint-plugin-jsx-a11y 6.10.2. Catches missing alt, label-less inputs, positive tabindex, invalid roles, at edit time.
// .eslintrc — extends, then runs in your existing lint step
{ "extends": ["plugin:jsx-a11y/recommended"] }
Unit (fast, no browser) — jest-axe 10.0.0. Asserts no axe violations on rendered output. Remember: contrast is off in jsdom.
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
test("no a11y violations", async () => {
const { container } = render(<SignupForm />);
expect(await axe(container)).toHaveNoViolations();
});
Browser (the real thing, catches contrast) — @axe-core/playwright 4.11.3 (on axe-core 4.12.0). Scope it to the WCAG 2.2 AA tags:
import AxeBuilder from "@axe-core/playwright";
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
.analyze();
expect(results.violations).toEqual([]);
Lighthouse a11y score is a smoke signal for a quick pulse, not proof — it runs a subset of axe and gives a number, not a pass.
scripts/verify.sh ties this together: it detects whatever tooling the project has and runs it, failing only on serious/critical violations (read-only, skips cleanly when no tooling is present).
Do these by hand before you call it done:
prefers-reduced-motion honored — no autoplay parallax/animation that ignores it.alt="".Full AA checklist grouped by POUR, with the per-item auto/manual split and the 6 new 2.2 criteria flagged → references/wcag22-checklist.md.
| Anti-pattern | Why it fails | Do instead |
| ---------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------- |
| <div role="button" onClick> | No keyboard, no focus, you owe all behavior by hand | <button> |
| outline: none with no replacement | Keyboard users lose all focus location (2.4.7) | :focus-visible ring |
| Positive tabindex (tabindex="3") | Hijacks page tab order, breaks for everyone | DOM order + tabindex="0"/-1 |
| Placeholder as the only label | Disappears on input, many SRs skip it | real <label for> |
| aria-label on a non-interactive <div> text | Duplicates or overrides visible text confusingly | label only interactive/landmark elements |
| aria-hidden="true" on a focusable element | Reachable by Tab but silent — a trap | remove from tab order too, or don't hide it |
| Error shown by red color only | Invisible to color-blind / low-vision users (1.4.1) | color + text + icon, aria-describedby |
| Redundant role="button" on <button> | Noise; native role is already correct | drop the role |
| Shipping on a green axe run | Covers ~57%; keyboard/SR/cognitive untested | run the manual checklist |
| Autoplaying motion, no reduced-motion guard | Triggers vestibular disorders (2.3.3) | gate behind prefers-reduced-motion |
../testing-web/SKILL.md (this skill supplies the a11y *assertions* that run inside it)../e2e-testing/SKILL.md../design/SKILL.md (this skill checks the contrast/target-size *outcome*, not the aesthetic)../react/SKILL.md / ../nextjs/SKILL.mdTake ericrisco/accessibility from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.