Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download ericrisco-rsc-harness-skills_accessibility-953fef5.zip · 15 KB
Part of ericrisco/rsc-harness — 46 skills

Install

skills CLI npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/accessibility
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
Git 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.

  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.

<!-- 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. 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

/* 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-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
<!-- 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-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)
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.

No comments yet.

Reviews (0)

No reviews yet.

Related