accessibility
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 `testi
Install
npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/accessibility
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
git clone https://github.com/ericrisco/rsc-harness.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole ericrisco/rsc-harness collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Accessibility — Ship WCAG 2.2 AA, not vibes
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.
The loop (your 30-second model)
Run these in order. Skipping a step front-loads rework.
- Native semantics first. A real
<button>ships focus, keyboard, and role for free. Most "a11y bugs" are a<div>doing a button's job. - Automated scan. axe-core catches roughly 57% of WCAG issues — missing labels, bad roles, contrast (in a real browser), duplicate ids. Cheap, run it every commit.
- Manual checklist for the rest. The other ~43% — keyboard order, focus traps, meaningful alt text, screen-reader flow — no engine can judge. A human (or you, deliberately) must.
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.
Rule 0 — reach for HTML before ARIA
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).
Semantics & accessible names
- One
<h1>per page. Headings describe structure; never skip a level (<h2>then<h4>) to get a font size — that's a CSS job. - Landmark every region:
<header> <nav> <main> <footer>. Exactly one<main>. Screen-reader users jump by landmark; a wall of<div>has no map. - Skip link first in the DOM so keyboard users escape the nav:
<a href="#main" class="sr-only-focusable">Skip to content</a>. - Accessible name precedence (what a screen reader announces), highest wins:
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>.
Keyboard operability
Everything a mouse can do, a keyboard must do.
- All interactive elements reachable and operable with Tab + Enter/Space. Native controls give this free; custom ones don't.
- Tab order follows the DOM. Fix order by reordering markup, not by patching
tabindex. - Never a positive
tabindex. Only0(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. - Visible focus, always (see next section). If you can't tell where focus is with the mouse unplugged, neither can the user.
Overlays (dialogs, menus, drawers) need focus management — three obligations:
- Move focus in when it opens (to the dialog or its first control).
- Trap focus inside while open — Tab from the last element wraps to the first.
- Escape closes, and focus returns to the trigger that opened it.
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.
Visible focus & the WCAG 2.2 deltas
/* 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:
- 2.4.11 Focus Not Obscured (Minimum) — a focused element must not be fully hidden behind sticky headers/footers or cookie bars.
- 2.5.7 Dragging Movements — anything done by dragging (sliders, reorder, map pan) needs a single-pointer alternative (tap, buttons).
- 2.5.8 Target Size (Minimum) — interactive targets are at least 24×24 CSS px, unless spacing keeps a 24px-radius circle from overlapping a neighbor (the spacing exception). 44×44 is the comfort bar; 24 is the floor.
- 3.2.6 Consistent Help — help mechanisms appear in the same relative order across pages.
- 3.3.7 Redundant Entry — don't make users re-enter info they already gave in the same process; auto-fill or let them pick it.
- 3.3.8 Accessible Authentication (Minimum) — no cognitive-function test to log in (no puzzles, no "transcribe this", no math). Allow paste, password managers, and copy.
Contrast & color
Measured ratios, AA minimums:
- 4.5:1 for normal text.
- 3:1 for large text (≥24px, or ≥18.66px bold).
- 3:1 for UI components and graphical objects you must perceive (1.4.11) — input borders, icon glyphs, chart segments.
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.
ARIA done right
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.
- State attributes:
aria-expandedon a disclosure trigger,aria-controlspointing at what it toggles,aria-selected/aria-currentfor the active item. Toggle them in the same handler that changes the visual state. - Live regions announce async changes without moving focus:
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.
- Hiding — pick the right one:
| 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>
Automate it (versioned, 2026-06-02)
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).
Manual checklist (the ~43% a machine can't see)
Do these by hand before you call it done:
- Unplug the mouse. Tab through the entire flow — every control reachable, order logical, focus always visible, no trap, Escape closes overlays.
- One screen-reader spot check — VoiceOver (macOS, ⌘F5) or NVDA (Windows). Do names, roles, and state read sensibly? Are errors announced?
- Zoom to 200% — no content lost, no horizontal scroll, nothing clipped.
-
prefers-reduced-motionhonored — no autoplay parallax/animation that ignores it. - Alt text is meaningful, not decorative-as-content — informative images describe; decorative images use
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-patterns
| 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 |
Hand off to
- Test harness, render setup, fixtures, CI runner mechanics →
../testing-web/SKILL.md(this skill supplies the a11y assertions that run inside it) - Full browser-driven flow orchestration →
../e2e-testing/SKILL.md - Visual intent, color palette, spacing scale →
../design/SKILL.md(this skill checks the contrast/target-size outcome, not the aesthetic) - Framework component architecture →
../react/SKILL.md/../nextjs/SKILL.md - Page speed / LCP / Core Web Vitals tuning → the performance skill (not an a11y concern)
Files (rsc-harness)
-
evals
-
cases.yaml 2.9 KB
skill: accessibility should_trigger: - prompt: "Make this signup form accessible — WCAG 2.2 AA." why: Direct conformance ask against the exact standard this skill owns. - prompt: "axe-core reports color-contrast and button-name violations, fix them." why: Tool-output trigger; named axe rules are this skill's home turf. - prompt: "The modal doesn't trap focus and Escape doesn't close it." why: Non-obvious phrasing — focus management for overlays, no a11y keyword in sight. - prompt: "Tab order jumps around and the icon-only buttons have no label for screen readers." why: Keyboard order plus accessible-name problem; core operable/perceivable work. - prompt: "El lector de pantalla no lee los errores del formulario, fes-ho accessible." why: Spanish/Catalan trigger — live region for errors plus general conformance ask. - prompt: "Our tap targets are smaller than 24px — does WCAG 2.2 require bigger ones?" why: Non-obvious specificity on SC 2.5.8 Target Size (Minimum), a 2.2 delta. - prompt: "Lighthouse accessibility score is 62, get it into the green." why: Low a11y score is a direct entry point even though Lighthouse is multi-purpose. should_not_trigger: - prompt: "The page LCP is 4 seconds, make it load faster." route_to: performance why: Page-speed / Core Web Vitals tuning, not WCAG conformance. - prompt: "Set up the Jest + React Testing Library harness for this repo." route_to: testing-web why: Test runner / render setup mechanics; this skill only supplies the a11y assertions that run inside it. - prompt: "Write Playwright E2E tests for the checkout flow." route_to: e2e-testing why: Full browser-driven flow orchestration, not a WCAG audit. - prompt: "Pick the brand colors and spacing scale for the design system." route_to: design why: Visual intent and design tokens; this skill checks the contrast/target-size outcome, not the aesthetic. - prompt: "Sanitize this user input to prevent XSS." route_to: secure-coding why: Security hardening, unrelated to accessibility conformance. capability: - scenario: > Given a React component where an icon-only toggle is a <div onClick> inside a card, with no labels, a custom brand color for the glyph, and no keyboard handler — produce an accessible rewrite plus a way to verify it. must_include: - Replaces the div with a native <button> (Rule 0 — native HTML before ARIA). - Adds an accessible name (aria-label or visually-hidden text) so the icon-only control is announced. - Ensures keyboard operability and visible focus (:focus-visible rather than outline:none). - Flags that contrast must be checked in a real browser because jsdom/jest-axe can't compute it. - Adds an automated check (jest-axe, or @axe-core/playwright scoped to the wcag22aa tag). - States automation catches only ~57% of issues and names at least one manual check (keyboard pass or screen-reader spot check). -
README.md 1.2 KB
# Evals — accessibility These cases are graded by eye, not by an auto-runner. Read `cases.yaml` against the live `SKILL.md` description and decide: every `should_trigger` prompt should clearly select **accessibility** (note that several are deliberately non-obvious — a focus-trap complaint, a tap-target-size question, and a Catalan/Spanish phrasing — so the description's `Triggers:` list has to cover them), while each `should_not_trigger` prompt should route to the named sibling instead (`performance`, `testing-web`, `e2e-testing`, `design`, `secure-coding`) and not pull this skill in. For the `capability` case, prompt the agent with the scenario and read its output for each `must_include` point — it passes only if the rewrite replaces the div with a native `<button>`, gives the control an accessible name, makes it keyboard-operable with a visible focus ring, flags that contrast needs a real browser, wires in an automated check (jest-axe or @axe-core/playwright with the `wcag22aa` tag), and explicitly says automation only covers ~57% while naming a manual check. There is no scoring script; this is a judgement pass over the skill's text and the agent's response.
-
-
references
-
aria-patterns.md 5.1 KB
# Accessible patterns (copy-ready) The minimal correct ARIA + keyboard behavior for the widgets you can't build from a single native element. Written fresh; verify intent against the W3C ARIA Authoring Practices Guide. Reach for these **only** when no native element fits (Rule 0). Recurring obligation for everything custom: **Name, Role, Value** — a name, the right role, and *state kept in sync* with the handler that changes the visuals. --- ## Modal dialog Prefer native `<dialog>` + `el.showModal()` — it gives you the role, the backdrop, focus trap, and Escape-to-close for free. Build by hand only if you can't. If hand-rolling: `role="dialog"` (or `alertdialog`), `aria-modal="true"`, and `aria-labelledby` pointing at the title. | Key | Behavior | | -------------- | ------------------------------------------------- | | Open | Move focus into the dialog (first control/title). | | `Tab` | Cycle within; from last element wrap to first. | | `Shift+Tab` | From first element wrap to last. | | `Esc` | Close; **return focus to the trigger**. | Trap focus while open; mark the rest of the page inert (`inert` attribute or `aria-hidden` on the background). Never leave focus loose behind the overlay. ## Disclosure / accordion A button that shows/hides a region. Native `<details><summary>` covers most cases — use it first. Hand-rolled: a `<button>` with `aria-expanded` and `aria-controls` pointing at the panel id. | Key | Behavior | | --------------- | --------------------------------- | | `Enter`/`Space` | Toggle the panel; flip `aria-expanded`. | ```html <button aria-expanded="false" aria-controls="sect1">Details</button> <div id="sect1" hidden>…</div> ``` Toggle `aria-expanded` **and** the `hidden` attribute in the same handler. ## Tabs `role="tablist"` wraps `role="tab"` buttons; each tab `aria-controls` its `role="tabpanel"`. The active tab has `aria-selected="true"`; the panel has `tabindex="0"`. Use **roving tabindex**: active tab `tabindex="0"`, others `-1`. | Key | Behavior | | ------------------- | ----------------------------------------- | | `Tab` | Into the tablist (one stop), then panel. | | `←` / `→` | Move between tabs (horizontal tablist). | | `Home` / `End` | First / last tab. | | `Enter`/`Space` | Activate (if not auto-activating). | ## Combobox (autocomplete) `role="combobox"` on the input with `aria-expanded`, `aria-controls` → the listbox, and `aria-activedescendant` → the highlighted option id. The popup is `role="listbox"` of `role="option"`. | Key | Behavior | | --------------- | --------------------------------------------------- | | `↓` / `↑` | Open list / move highlight; update `aria-activedescendant`. | | `Enter` | Select the highlighted option, collapse. | | `Esc` | Close the list, keep or clear the value. | | typing | Filter; keep focus in the input the whole time. | Focus stays in the input — you move a virtual highlight via `aria-activedescendant`, not real focus. ## Menu button A `<button aria-haspopup="menu" aria-expanded>` opens a `role="menu"` of `role="menuitem"`. Roving tabindex inside. | Key | Behavior | | ------------------- | ----------------------------------------- | | `Enter`/`Space`/`↓` | Open menu, focus first item. | | `↑` / `↓` | Move between items. | | `Home` / `End` | First / last item. | | `Esc` | Close, return focus to the button. | | `Enter` | Activate item, close. | Note: a simple navigation dropdown of links is often better as a plain disclosure of `<a>` elements — don't reach for `role="menu"` unless it's an application action menu. ## Toast / live region Transient status messages. Don't move focus to them — announce via a live region that already exists in the DOM. - `role="status"` / `aria-live="polite"` — non-urgent ("Saved", "3 results"). Waits for a pause. - `role="alert"` / `aria-live="assertive"` — urgent ("Submit failed", "Session expiring"). Interrupts. Use sparingly. ```html <!-- present in the DOM before the message; inject text into it --> <div role="status" aria-live="polite" class="sr-only"></div> ``` The region must exist *before* you write into it — injecting both the container and the text at once often isn't announced. --- ## The `.sr-only` utility Visually hide while keeping it in the accessibility tree (skip links, live regions, icon-button names): ```css .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } ``` Do **not** use `display:none` or `visibility:hidden` for this — both remove the text from screen readers too. -
wcag22-checklist.md 6.4 KB
# WCAG 2.2 Level AA checklist (grouped by POUR) The conformance bar. Each item is tagged how it's best verified: - **[auto]** — an engine (axe-core) catches it reliably. - **[manual]** — needs a human (keyboard, screen reader, judgement). - **[both]** — automation flags candidates, a human confirms intent. **NEW** marks a criterion introduced in WCAG 2.2 (W3C Recommendation 2023-10-05). WCAG 2.2 added 9 criteria and **removed 4.1.1 Parsing** — don't chase it anymore. This is the AA conformance set (A + AA). Numbers (`x.y.z`) are the official SC ids so you can cross-reference the spec. --- ## Perceivable - **1.1.1 Non-text Content (A)** — every image/icon/control has a text alternative; decorative images use `alt=""`. **[both]** (axe flags missing alt; a human judges whether it's *meaningful*). - **1.3.1 Info and Relationships (A)** — structure is in the markup: headings, lists, `<label for>`, `<th scope>`, fieldset/legend. **[both]** - **1.3.2 Meaningful Sequence (A)** — DOM order matches reading order; don't reorder with CSS in a way that breaks it. **[manual]** - **1.3.4 Orientation (AA)** — don't lock to portrait/landscape unless essential. **[manual]** - **1.3.5 Identify Input Purpose (AA)** — use `autocomplete` tokens on personal-data fields (`name`, `email`, `tel`). **[auto]** - **1.4.1 Use of Color (A)** — color is never the only way to convey info; pair with text/icon/shape. **[manual]** - **1.4.3 Contrast (Minimum) (AA)** — text **4.5:1**; large text (≥24px or ≥18.66px bold) **3:1**. **[auto in a real browser; jsdom can't]** - **1.4.4 Resize Text (AA)** — text scales to 200% with no loss of content/function. **[manual]** - **1.4.5 Images of Text (AA)** — use real text, not pictures of text, except logos. **[manual]** - **1.4.10 Reflow (AA)** — content reflows to a 320px-wide viewport with no 2-D scrolling. **[manual]** - **1.4.11 Non-text Contrast (AA)** — UI components and meaningful graphics hit **3:1** (input borders, focus indicators, icon glyphs, chart segments). **[both]** - **1.4.12 Text Spacing (AA)** — no content clipped when users override line/letter/word spacing. **[manual]** - **1.4.13 Content on Hover or Focus (AA)** — hover/focus popups are dismissable, hoverable, and persistent. **[manual]** ## Operable - **2.1.1 Keyboard (A)** — all functionality available from the keyboard. **[manual]** - **2.1.2 No Keyboard Trap (A)** — focus can always move away with the keyboard. **[manual]** - **2.1.4 Character Key Shortcuts (A)** — single-key shortcuts can be turned off/remapped or only fire on focus. **[manual]** - **2.4.1 Bypass Blocks (A)** — a skip link or landmarks let users bypass repeated content. **[both]** - **2.4.2 Page Titled (A)** — every page has a unique, descriptive `<title>`. **[auto]** - **2.4.3 Focus Order (A)** — focus order preserves meaning and operability. **[manual]** - **2.4.4 Link Purpose (In Context) (A)** — link text says where it goes; no bare "click here". **[both]** - **2.4.5 Multiple Ways (AA)** — more than one way to find a page (nav, search, sitemap). **[manual]** - **2.4.6 Headings and Labels (AA)** — headings and labels describe topic/purpose. **[manual]** - **2.4.7 Focus Visible (AA)** — keyboard focus is always visibly indicated. **[both]** - **2.4.11 Focus Not Obscured (Minimum) (AA)** — **NEW** — a focused element isn't entirely hidden by sticky headers/footers/cookie bars. **[manual]** - **2.5.1 Pointer Gestures (A)** — multipoint/path gestures have a single-pointer alternative. **[manual]** - **2.5.2 Pointer Cancellation (A)** — actions fire on up-event, allow abort. **[manual]** - **2.5.3 Label in Name (A)** — the accessible name contains the visible label text. **[both]** - **2.5.4 Motion Actuation (A)** — device-motion features have a UI alternative and can be disabled. **[manual]** - **2.5.7 Dragging Movements (AA)** — **NEW** — anything by dragging has a single-pointer (tap/button) alternative. **[manual]** - **2.5.8 Target Size (Minimum) (AA)** — **NEW** — interactive targets ≥ **24×24 CSS px**, or spaced so a 24px circle doesn't overlap neighbors (spacing exception). **[both]** - **2.3.1 Three Flashes or Below (A)** — nothing flashes more than 3×/second. **[manual]** ## Understandable - **3.1.1 Language of Page (A)** — `<html lang="…">` set. **[auto]** - **3.1.2 Language of Parts (AA)** — inline language changes marked with `lang`. **[manual]** - **3.2.1 On Focus (A)** — focus alone doesn't trigger a context change. **[manual]** - **3.2.2 On Input (A)** — changing a setting doesn't auto-change context without warning. **[manual]** - **3.2.3 Consistent Navigation (AA)** — repeated nav stays in the same relative order. **[manual]** - **3.2.4 Consistent Identification (AA)** — same-function components are labeled consistently. **[manual]** - **3.2.6 Consistent Help (A)** — **NEW** — help (contact, FAQ link, chat) appears in the same relative order across pages. **[manual]** - **3.3.1 Error Identification (A)** — errors are identified in text and described. **[both]** - **3.3.2 Labels or Instructions (A)** — inputs have labels/instructions. **[both]** - **3.3.3 Error Suggestion (AA)** — when known, suggest a correction. **[manual]** - **3.3.4 Error Prevention (Legal/Financial/Data) (AA)** — reversible/checked/confirmed submissions. **[manual]** - **3.3.7 Redundant Entry (A)** — **NEW** — don't ask for info already provided in the same process; auto-populate or let them select it. **[manual]** - **3.3.8 Accessible Authentication (Minimum) (AA)** — **NEW** — no cognitive-function test to authenticate (no puzzles, no transcription); allow paste and password managers. **[manual]** ## Robust - **4.1.2 Name, Role, Value (A)** — every UI component exposes a correct name, role, and current state/value to assistive tech. **[both]** - **4.1.3 Status Messages (AA)** — status updates are announced without moving focus (live regions / roles). **[both]** > 4.1.1 Parsing was **removed** in WCAG 2.2. Modern browsers recover from minor markup errors; the criterion no longer applies. --- ## How to work this list 1. Run axe (browser, `wcag22aa` tag) — clears most **[auto]** rows. 2. Do a keyboard-only pass — clears the operable **[manual]** rows. 3. One screen-reader spot check — names/roles/state, live-region announcements (4.1.2, 4.1.3). 4. Zoom 200% + 320px reflow — 1.4.4 / 1.4.10. 5. Manually verify the 2.2 NEW rows; they're almost all **[manual]** and the ones most teams miss.
-
-
scripts
-
verify.sh 4.3 KB
#!/usr/bin/env bash # verify.sh — accessibility conformance check (read-only by default). # # Detects the project's a11y tooling and runs the cheapest verdict it can: # 1. an explicit a11y test script in package.json (e.g. "test:a11y") # 2. @axe-core/playwright -> needs a URL; runs an inline scan only if A11Y_URL is set # 3. jest-axe -> runs the project's jest tests # 4. eslint-plugin-jsx-a11y -> lints src and counts jsx-a11y violations # 5. nothing installed -> prints install hints and EXITS 0 (skip, never a false failure) # # Exits non-zero ONLY when serious/critical violations are found. # Usage: verify.sh [target-dir] (default: current directory) set -euo pipefail TARGET="${1:-.}" cd "$TARGET" say() { printf '%s\n' "$*"; } if [ ! -f package.json ]; then say "a11y: no package.json in '$TARGET' — nothing to verify. (skip)" exit 0 fi # read a top-level field out of package.json without extra tooling pkg_has() { node -e "const p=require('./package.json');const d={...p.dependencies,...p.devDependencies};process.exit(d['$1']?0:1)" 2>/dev/null; } script_has() { node -e "const p=require('./package.json');process.exit((p.scripts||{})['$1']?0:1)" 2>/dev/null; } PM="npm" if [ -f pnpm-lock.yaml ]; then PM="pnpm"; elif [ -f yarn.lock ]; then PM="yarn"; fi run_script() { case "$PM" in pnpm) pnpm run "$1";; yarn) yarn "$1";; *) npm run "$1";; esac; } # 1) explicit a11y script wins for s in test:a11y a11y test:accessibility accessibility; do if script_has "$s"; then say "a11y: running '$s' via $PM" run_script "$s" exit $? fi done # 2) @axe-core/playwright — needs a live URL if pkg_has "@axe-core/playwright"; then if [ -z "${A11Y_URL:-}" ]; then say "a11y: @axe-core/playwright present but A11Y_URL not set." say " Set A11Y_URL=http://localhost:3000 (with the app running) to scan. (skip)" exit 0 fi say "a11y: scanning $A11Y_URL with @axe-core/playwright (wcag2a,wcag2aa,wcag22aa)" node - "$A11Y_URL" <<'NODE' const { chromium } = require('playwright'); const AxeBuilder = require('@axe-core/playwright').default; (async () => { const url = process.argv[2]; const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'load' }); const r = await new AxeBuilder({ page }).withTags(['wcag2a','wcag2aa','wcag22aa']).analyze(); await browser.close(); const by = {}; for (const v of r.violations) by[v.impact || 'unknown'] = (by[v.impact || 'unknown'] || 0) + 1; console.log('a11y: violations by impact ' + (JSON.stringify(by) || '{}')); const blocking = (by.serious || 0) + (by.critical || 0); if (blocking > 0) { console.error(`a11y: ${blocking} serious/critical violation(s) — FAIL`); process.exit(1); } console.log('a11y: no serious/critical violations'); })().catch(e => { console.error('a11y: scan error — ' + e.message + ' (skip)'); process.exit(0); }); NODE exit $? fi # 3) jest-axe — run jest if a test script exists if pkg_has "jest-axe"; then if script_has "test"; then say "a11y: jest-axe present — running '$PM test' (note: contrast disabled in jsdom)" run_script test exit $? fi say "a11y: jest-axe present but no 'test' script. (skip)" exit 0 fi # 4) eslint-plugin-jsx-a11y — lint and count if pkg_has "eslint-plugin-jsx-a11y"; then SRC="src"; [ -d "$SRC" ] || SRC="." say "a11y: linting $SRC with eslint (jsx-a11y rules)" OUT="$(npx --no-install eslint "$SRC" --ext .js,.jsx,.ts,.tsx -f json 2>/dev/null || true)" if [ -z "$OUT" ]; then say "a11y: eslint produced no output (config or install issue). (skip)" exit 0 fi COUNT="$(printf '%s' "$OUT" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const r=JSON.parse(s);let n=0;for(const f of r)for(const m of f.messages)if((m.ruleId||'').startsWith('jsx-a11y/'))n++;console.log(n)}catch{console.log(0)}})")" say "a11y: jsx-a11y violations: $COUNT" [ "$COUNT" -gt 0 ] && exit 1 exit 0 fi # 5) nothing detected — guide, don't fail say "a11y: no a11y tooling detected. (skip)" say " Install one of:" say " npm i -D eslint-plugin-jsx-a11y # static lint of JSX" say " npm i -D jest-axe # unit-level axe (contrast off in jsdom)" say " npm i -D @axe-core/playwright # real-browser scan incl. contrast" exit 0
-
-
SKILL.md 13.3 KB
--- name: accessibility description: "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`)." tags: [wcag, accessibility, a11y, aria, axe-core] recommends: [testing-web, e2e-testing, design, react, performance] origin: risco --- # Accessibility — Ship WCAG 2.2 AA, not vibes *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.* ## The loop (your 30-second model) Run these in order. Skipping a step front-loads rework. 1. **Native semantics first.** A real `<button>` ships focus, keyboard, and role for free. Most "a11y bugs" are a `<div>` doing a button's job. 2. **Automated scan.** axe-core catches roughly **57%** of WCAG issues — missing labels, bad roles, contrast (in a real browser), duplicate ids. Cheap, run it every commit. 3. **Manual checklist for the rest.** The other **~43%** — keyboard order, focus traps, meaningful alt text, screen-reader flow — no engine can judge. A human (or you, deliberately) must. **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. ## Rule 0 — reach for HTML before ARIA 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. ```html <!-- 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`). ## Semantics & accessible names - **One `<h1>` per page.** Headings describe structure; never skip a level (`<h2>` then `<h4>`) to get a font size — that's a CSS job. - **Landmark every region:** `<header> <nav> <main> <footer>`. Exactly one `<main>`. Screen-reader users jump by landmark; a wall of `<div>` has no map. - **Skip link first in the DOM** so keyboard users escape the nav: `<a href="#main" class="sr-only-focusable">Skip to content</a>`. - **Accessible name precedence** (what a screen reader announces), highest wins: `aria-labelledby` → `aria-label` → associated `<label>` / element text → `title`. Don't stack them hoping one sticks; pick one source. ```html <!-- 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>`. ## Keyboard operability Everything a mouse can do, a keyboard must do. - **All interactive elements reachable and operable** with Tab + Enter/Space. Native controls give this free; custom ones don't. - **Tab order follows the DOM.** Fix order by reordering markup, not by patching `tabindex`. - **Never a positive `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. - **Visible focus, always** (see next section). If you can't tell where focus is with the mouse unplugged, neither can the user. **Overlays (dialogs, menus, drawers) need focus management** — three obligations: 1. **Move focus in** when it opens (to the dialog or its first control). 2. **Trap focus** inside while open — Tab from the last element wraps to the first. 3. **Escape closes**, and **focus returns to the trigger** that opened it. 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`. ## Visible focus & the WCAG 2.2 deltas ```css /* 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: - **2.4.11 Focus Not Obscured (Minimum)** — a focused element must not be fully hidden behind sticky headers/footers or cookie bars. - **2.5.7 Dragging Movements** — anything done by dragging (sliders, reorder, map pan) needs a single-pointer alternative (tap, buttons). - **2.5.8 Target Size (Minimum)** — interactive targets are at least **24×24 CSS px**, unless spacing keeps a 24px-radius circle from overlapping a neighbor (the spacing exception). 44×44 is the comfort bar; 24 is the floor. - **3.2.6 Consistent Help** — help mechanisms appear in the same relative order across pages. - **3.3.7 Redundant Entry** — don't make users re-enter info they already gave in the same process; auto-fill or let them pick it. - **3.3.8 Accessible Authentication (Minimum)** — no cognitive-function test to log in (no puzzles, no "transcribe this", no math). Allow paste, password managers, and copy. ## Contrast & color Measured ratios, AA minimums: - **4.5:1** for normal text. - **3:1** for large text (**≥24px**, or **≥18.66px bold**). - **3:1** for UI components and graphical objects you must perceive (1.4.11) — input borders, icon glyphs, chart segments. **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. ```html <!-- 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. ## ARIA done right 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. - **State attributes:** `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. - **Live regions** announce async changes without moving focus: - `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. - **Hiding — pick the right one:** | 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 | ```html <!-- 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> ``` ## Automate it (versioned, 2026-06-02) 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. ```jsonc // .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.** ```js 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: ```js 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). ## Manual checklist (the ~43% a machine can't see) Do these by hand before you call it done: - [ ] **Unplug the mouse.** Tab through the entire flow — every control reachable, order logical, focus always visible, no trap, Escape closes overlays. - [ ] **One screen-reader spot check** — VoiceOver (macOS, ⌘F5) or NVDA (Windows). Do names, roles, and state read sensibly? Are errors announced? - [ ] **Zoom to 200%** — no content lost, no horizontal scroll, nothing clipped. - [ ] **`prefers-reduced-motion`** honored — no autoplay parallax/animation that ignores it. - [ ] **Alt text is meaningful, not decorative-as-content** — informative images describe; decorative images use `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-patterns | 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` | ## Hand off to - Test harness, render setup, fixtures, CI runner mechanics → `../testing-web/SKILL.md` (this skill supplies the a11y *assertions* that run inside it) - Full browser-driven flow orchestration → `../e2e-testing/SKILL.md` - Visual intent, color palette, spacing scale → `../design/SKILL.md` (this skill checks the contrast/target-size *outcome*, not the aesthetic) - Framework component architecture → `../react/SKILL.md` / `../nextjs/SKILL.md` - Page speed / LCP / Core Web Vitals tuning → the **performance** skill (not an a11y concern)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.