ProstDev ProstDev
Intermediate Added Jul 9, 2026 · 27 min read

Accessibility Review

accessibility-review

Point it at any component and get real WCAG 2.2 findings — the criterion, the file, the fix, and how to actually verify it.

Claude Code skill

Note

This one is framework-agnostic on purpose — plain HTML, React, Vue, Svelte, Astro, web components, it doesn’t care. It’s the accessibility skill I reach for on any project, not just this site.

What it does

accessibility-review is a Claude Code skill that reviews the accessibility of web UI against WCAG 2.2, Level A and AA — the conformance target most teams and regulations actually aim for. You point it at a component, a page, or a diff, and it finds the real violations, tells you which success criterion each one breaks, and gives you the fix.

The thing it’s careful about: a finding has to be a clear, direct violation of a specific criterion. No vague “this could be more accessible” noise that buries the issues that matter. Every reported problem comes with the SC number, the location, the user impact, and a corrected snippet.

When to use it

Ask Claude to review, audit, or fix accessibility on anything that renders a visible or interactive surface — or when the conversation turns to screen readers, keyboard navigation, color contrast, focus management, or ARIA (the accessibility attributes that describe custom widgets to assistive tech). It works while you’re writing a component too, not just after.

Note

“a11y” = “accessibility.” It’s a numeronym — the a, the 11 letters in between, and the y. You’ll see it everywhere in this space, so it’s worth knowing, but this skill spells it out so you never have to decode it.

How it works

It’s a small orchestrator with two reference files it opens on demand (so the skill stays lightweight until it’s actually doing a review):

  1. Triage first — look at what the code renders and match checks to what’s actually there. A static paragraph engages almost nothing; a custom <div>-based dropdown engages a dozen criteria.
  2. The checklist (reference/wcag-checklist.md) — ~30 Level A/AA criteria grouped by the four WCAG principles, each with what it requires → common failures → how to check → the fix with code, plus an “if the code has X, check Y” triage table.
  3. Verify, don’t eyeball (reference/verification-guide.md) — the actual contrast-ratio math (including the trap where opacity silently breaks contrast), the keyboard walk, the screen-reader pass, and the zoom/reflow checks. A guessed contrast ratio isn’t a finding.

Two rules run through the whole thing: semantic HTML first, ARIA as a last resort (a native <button> carries role, state, and keyboard behavior for free; bad ARIA is worse than none), and verify in every theme the UI ships (the palette changes, so a contrast pass in light mode isn’t a pass in dark).

The skill definition

The skill is three files — the SKILL.md entry point plus its two reference docs. All three are below, verbatim; drop the folder into any project’s .claude/skills/ to use it.

Note

New to skills? The folder itself has to be named accessibility-review (it must match the name: in the SKILL.md frontmatter). Claude Code discovers the skill by that folder, and you invoke it as /accessibility-review. Here’s the exact layout:

.claude/
└─ skills/
   └─ accessibility-review/          ← folder name MUST match the skill's name
      ├─ SKILL.md                    ← entry point (has "name: accessibility-review")
      └─ reference/
         ├─ wcag-checklist.md
         └─ verification-guide.md

SKILL.md

---
name: accessibility-review
description: Review, audit, or fix the accessibility of web UI — HTML, CSS, JS/JSX, or any component that renders a visible or interactive surface. Use when the user asks to check accessibility / a11y / WCAG / ARIA compliance, or mentions screen readers, keyboard navigation, color contrast, focus management, or making something usable for people with disabilities. Covers ~30 WCAG 2.2 Level A & AA success criteria with detection and remediation guidance.
---

# Accessibility review

A structured accessibility review against **WCAG 2.2, Level A and AA** — the conformance target most
teams and regulations aim for. Works on any web UI regardless of framework (plain HTML, React, Vue,
Svelte, Astro, web components, etc.).

## How to run a review

1. **Triage — figure out which criteria even apply.** Look at what the code renders: images? forms?
   links? custom widgets built from `<div>`s? color-coded states? drag interactions? A static
   paragraph engages almost nothing; a custom dropdown engages a dozen criteria. Don't run every
   check on every element — match checks to what's actually there.
2. **Open the checklist for the applicable criteria.** `reference/wcag-checklist.md` is the full
   per-criterion guide (grouped Perceivable / Operable / Understandable / Robust). Each entry has
   *what it requires → common failures → how to check → the fix + a code example*. Read the entries
   that apply; skip the rest.
3. **Verify, don't eyeball.** For anything measurable — contrast ratios, target sizes, keyboard
   reachability, zoom/reflow — follow `reference/verification-guide.md`. It has the actual
   contrast-ratio math (including the opacity trap), the keyboard/screen-reader walkthroughs, and the
   multi-theme rule. A guessed contrast ratio is not a finding.
4. **Report findings and fix them** (output format below).

## Golden rules (apply throughout)

- **A finding must be a *direct, clear* violation of a specific criterion.** Cite the SC number.
  Don't report vague "could be more accessible" opinions or general code-style issues — that noise
  buries the real violations. If it doesn't clearly break a criterion, leave it out.
- **Semantic HTML first; ARIA is the last resort.** A native `<button>`, `<a href>`, `<label>`,
  `<nav>`, `<h2>`, or `<input type>` carries role, state, and keyboard behavior for free. Reach for
  ARIA *only* when no native element does the job. Incorrect or redundant ARIA is worse than none —
  `role="button"` on a real `<button>`, or `aria-label` that fights the visible text, actively
  breaks assistive tech. Prefer *removing* a `<div onclick>` in favor of a `<button>` over bolting
  ARIA onto the `<div>`.
- **Trust well-implemented component libraries.** Established, accessible libraries (the platform's
  own design system, mature UI kits) are accessible when used as documented. Don't re-audit their
  internals — only flag them when they're clearly *mis*used against a criterion.
- **Verify in every theme/mode the UI ships.** Light, dark, high-contrast — the palette changes, so a
  contrast pass in one is not a pass in another. Same for RTL layouts and zoom states.
- **Always give the fix, not just the finding.** Every reported violation gets a concrete remediation
  — the corrected markup/CSS/handler, not "add a label."

## Output format

Group findings by severity, then list each as:

- **[SC number + name] — `file:line`**
- **What's wrong:** the specific violation (what a user with a disability can't do).
- **Fix:** the concrete change, with a corrected code snippet.

Severity: **Blocker** (a whole task is impossible for some users — no keyboard access, unlabeled
control, form with no error text) → **Serious** (usable but degraded — low contrast, poor focus
order) → **Minor** (edge cases, enhancements toward AA robustness). If a criterion has no violation,
don't mention it. End with a one-line note that any color/contrast fix must be re-verified in every
theme via the real ratio, not by eye.

## Scope notes

- **Target is A + AA.** AAA criteria (e.g. 7:1 contrast, 44×44 enhanced target size) are noted in the
  checklist as *optional enhancements*, not requirements — offer them, don't fail on them.
- This reviews the code's accessibility. It is **not** a general code review — stay on accessibility.
- WCAG 2.2 removed the old **4.1.1 Parsing** criterion (obsolete); it is intentionally absent.
- **Staleness check (do this once, not every run).** This skill targets **WCAG 2.2**, a stable W3C
  Recommendation. Don't burn tokens re-researching it on every invocation — it changes on the order of
  years. But WCAG 3.0 is in development, so if a long time has clearly passed, do a quick check that
  2.2 is still the current standard before trusting an exact threshold or criterion number, and refresh
  the checklist/verification-guide if it isn't. When in genuine doubt about a specific criterion, the
  authoritative source is [w3.org/WAI/WCAG22/quickref](https://www.w3.org/WAI/WCAG22/quickref/).

reference/wcag-checklist.md

# WCAG 2.2 A & AA checklist — detection & remediation

The per-criterion guide. Grouped by the four WCAG principles (**P**erceivable, **O**perable,
**U**nderstandable, **R**obust). Read only the entries that apply to the code under review (triage
first — see `SKILL.md`). Each entry: **Requires → Common failures → How to check → Fix (with code)**.

Level: **A** = must, **AA** = must for the standard conformance target, **🆕** = new in WCAG 2.2.
AAA enhancements are called out inline as *optional*.

Before hand-checking, a quick automated pass (axe DevTools, Lighthouse, Pa11y, or `eslint-plugin-jsx-a11y`)
catches the mechanical failures cheaply. Automated tools find only ~30–40% of issues — they never
replace the keyboard + screen-reader + contrast checks in `verification-guide.md`.

---

## Perceivable

### 1.1.1 Non-text Content — A
**Requires:** every non-text element that conveys meaning has a text alternative serving the
equivalent purpose. Decorative content is hidden from assistive tech.
**Common failures:** `<img>` with no `alt`; `alt=""` on a meaningful image; icon-only buttons/links
with no accessible name; SVG with no title/label; informative background images; `alt` that repeats
adjacent visible text; alt like "image" or the filename.
**How to check:** list every `<img>`, `<svg>`, `<canvas>`, icon font, and CSS background that carries
meaning. Ask: if the image vanished, is the information lost? If yes, it needs a text alternative. If
it's purely decorative, it must be `alt=""` (or `role="presentation"` / `aria-hidden`).
**Fix:**
```html
<!-- meaningful -->
<img src="chart.png" alt="Sales rose 40% from Q1 to Q2">
<!-- decorative -->
<img src="swoosh.png" alt="">
<!-- icon-only control: name it -->
<button aria-label="Close dialog"><svg aria-hidden="true">…</svg></button>
<!-- informative inline SVG -->
<svg role="img" aria-labelledby="t1"><title id="t1">Warning</title>…</svg>
```
Alt text describes *function* for controls ("Search", not "magnifying glass") and *content/meaning*
for images. Keep it concise; no "image of".

### 1.3.1 Info and Relationships — A
**Requires:** structure and relationships conveyed visually are also programmatically determinable —
via semantic HTML (preferred) or ARIA. Covers lists, tables, form-label association, headings,
grouping, and landmark regions.
**Common failures:**
- **Lists:** visually a list but marked up as `<div>`s or `<br>`-separated lines.
- **Tables:** data table with no `<th>`, no `scope`, or a data grid faked with `<div>`s; missing `<caption>`.
- **Form labels:** placeholder used *as* the label; label not associated (`for`/`id` mismatch);
  a visible label sitting near an input with no programmatic tie.
- **Headings:** text that looks like a heading (big/bold) but is a `<p>`; skipped levels
  (`<h2>``<h4>`); more than one `<h1>` for the page's main title.
- **Grouping:** a set of radios/checkboxes with no `<fieldset>`/`<legend>`.
- **Regions:** whole page in bare `<div>`s with no `<header>`/`<nav>`/`<main>`/`<footer>` landmarks.
**How to check:** turn off CSS (or read the DOM) — does the structure still make sense? Heading/landmark
navigation in a screen reader should mirror the visual structure.
**Fix:**
```html
<!-- real list -->
<ul><li>First</li><li>Second</li></ul>

<!-- data table with header scope + caption -->
<table>
  <caption>Q2 revenue by region</caption>
  <thead><tr><th scope="col">Region</th><th scope="col">Revenue</th></tr></thead>
  <tbody><tr><th scope="row">West</th><td>$1.2M</td></tr></tbody>
</table>

<!-- associated label -->
<label for="email">Email</label>
<input id="email" type="email" name="email">

<!-- grouped controls -->
<fieldset><legend>Notify me by</legend>
  <label><input type="radio" name="notify" value="email"> Email</label>
  <label><input type="radio" name="notify" value="sms"> SMS</label>
</fieldset>

<!-- landmarks -->
<header>…</header><nav aria-label="Primary">…</nav><main>…</main><footer>…</footer>
```
Body headings start at the level below the page title and never skip a level. The page title is the
only `<h1>`.

### 1.3.5 Identify Input Purpose — AA
**Requires:** inputs collecting the user's own info expose their purpose programmatically (so browsers
can autofill).
**Common failures:** name/email/address/phone/credit-card fields with no `autocomplete` attribute.
**Fix:** `<input name="email" autocomplete="email">`, `autocomplete="given-name"`, `"tel"`,
`"street-address"`, `"cc-number"`, etc. (from the HTML autofill token list).

### 1.4.1 Use of Color — A
**Requires:** color is never the *only* means of conveying information, indicating an action, or
distinguishing an element.
**Common failures:** required fields shown only by red text; errors flagged only by a red border; a
chart legend distinguishing series by color alone; links inside body text distinguished from
surrounding text by color only (with no underline) — this one specifically needs either a non-color
cue *or* a 3:1 contrast difference against the body text **and** a cue on hover/focus.
**How to check:** view in grayscale. Is any state/meaning now ambiguous?
**Fix:** pair color with a second cue — an icon, text label, underline, shape, or pattern.
```html
<span class="error"><svg aria-hidden="true">⚠</svg> Password is required</span>
<label for="pw">Password <span aria-hidden="true">*</span><span class="sr-only">(required)</span></label>
```

### 1.4.3 Contrast (Minimum) — AA
**Requires:** text contrast ≥ **4.5:1**; **3:1** for large text (≥ 24px, or ≥ 18.66px bold).
**Common failures:** light-gray "secondary" text; placeholder text as content; text over an image or
gradient with no scrim; text dimmed with `opacity`.
**How to check:** compute the real ratio — see `verification-guide.md` (it has the formula and the
critical `opacity` blend step). Never eyeball it.
**Fix:** darken/lighten the text color until it passes; add a solid or scrimmed background behind text
on images; don't use `opacity` to make text "secondary" — use a color that *passes* at full opacity.
*Optional AAA (1.4.6): 7:1 / 4.5:1 for large text.*

### 1.4.10 Reflow — AA
**Requires:** content reflows to a single column at **320 CSS px** width (≈ 400% zoom on a 1280px
viewport) with no loss of content/function and no two-dimensional scrolling (except for things that
inherently need it — data tables, maps, code).
**Common failures:** fixed-`px` widths on containers; horizontal scrollbars at narrow width; content
clipped or overlapping when zoomed; a flex/grid child that won't shrink (needs `min-width: 0`).
**How to check:** set the viewport to 320px (or zoom to 400%). Look for sideways scroll or clipping.
**Fix:** relative units + `max-width`, responsive layout, `min-width: 0` on grid/flex children that
hold wide content (long words, code, tables), and let genuinely-wide elements scroll *within their own
box*.

### 1.4.11 Non-text Contrast — AA
**Requires:****3:1** contrast for (a) UI-component boundaries needed to identify them — input
borders, button edges, toggle states — and (b) meaningful graphics/icons.
**Common failures:** an input outlined only by a near-invisible 1px border; a ghost/tertiary button
with no perceptible edge; a focus indicator below 3:1; a checked-state swatch that barely differs.
**How to check:** measure the control's boundary (and its focus indicator) against its adjacent
background — see `verification-guide.md`.
**Fix:** raise the border/indicator color until it hits 3:1 against both the fill and the page.

### 1.4.12 Text Spacing — AA
**Requires:** no clipping or content loss when the user overrides line-height to 1.5×, paragraph
spacing to 2×, letter-spacing to 0.12em, word-spacing to 0.16em.
**Common failures:** fixed-height text containers with `overflow: hidden` clipping text.
**Fix:** avoid fixed heights on text containers; use `min-height` and let content expand.

### 1.4.13 Content on Hover or Focus — AA
**Requires:** content that appears on hover/focus (tooltips, popovers, custom menus) is
**dismissible** (Esc without moving the pointer), **hoverable** (you can move onto it without it
vanishing), and **persistent** (stays until dismissed / focus moves / it's no longer valid).
**Common failures:** tooltip disappears the instant you move toward it; no Esc to dismiss; content
that covers other content and can't be cleared.
**Fix:** keep the popover open while pointer/focus is on the trigger *or* the content; wire `Escape` to
close; don't auto-hide on a timer.

---

## Operable

### 2.1.1 Keyboard — A
**Requires:** all functionality is operable from the keyboard alone.
**Common failures:** `<div onclick>` / `<span onclick>` with no key handler and not focusable; custom
widgets (dropdown, slider, tabs, modal) that only respond to mouse; hover-only menus; drag-only
interactions (see 2.5.7); a control reachable but not *activatable* by keyboard.
**How to check:** unplug the mouse. Tab through everything; activate with Enter/Space; operate widgets
with arrow keys per the ARIA Authoring Practices pattern. Anything you can't reach or operate is a
Blocker.
**Fix:** use native interactive elements (`<button>`, `<a href>`, `<input>`) — they're keyboard-operable
for free. If a custom widget is unavoidable, add `tabindex="0"`, a `role`, and key handlers matching
the [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/) for that pattern.
```html
<!-- bad -->
<div onclick="save()">Save</div>
<!-- good -->
<button type="button" onclick="save()">Save</button>
```

### 2.1.2 No Keyboard Trap — A
**Requires:** if you can Tab into a component, you can Tab out. Focus is never trapped (except
intentional, escapable traps like a modal that closes on Esc).
**Common failures:** a widget/embed that swallows Tab; a modal you can't leave and can't Esc out of.
**Fix:** ensure Tab/Shift+Tab exit; for modals, trap focus *within* the modal but provide Esc to close
and return focus to the trigger.

### 2.1.4 Character Key Shortcuts — A
**Requires:** single-character shortcuts (e.g. press `s` to search) can be turned off, remapped, or are
only active when the relevant component has focus.
**Common failures:** global single-key shortcuts that fire while typing in a field.
**Fix:** require a modifier, scope shortcuts to focus, or offer a way to disable them.

### 2.4.3 Focus Order — A
**Requires:** tab order preserves meaning and operability — it follows a logical reading/usage sequence.
**Common failures:** positive `tabindex` values (2, 3, …) that hijack order; DOM order that doesn't
match visual order after CSS reordering (flex/grid `order`, absolute positioning); a modal that opens
but focus stays behind it.
**How to check:** Tab through; the focus ring should move in the order a sighted user would expect.
**Fix:** order the DOM to match the visual flow; use only `tabindex="0"` / `tabindex="-1"`, never
positive values; move focus into a modal on open.

### 2.4.4 Link Purpose (In Context) — A
**Requires:** each link's purpose is clear from its text alone or its immediate context.
**Common failures:** "click here", "read more", "learn more" repeated with no distinguishing context;
a bare URL; an icon-only link with no name.
**Fix:** make link text descriptive ("Read the 2024 report"), or supply context via `aria-label` /
visually-hidden text. Multiple links going to *different* places must not share identical text.

### 2.4.6 Headings and Labels — AA
**Requires:** headings and form labels describe their topic or purpose.
**Common failures:** generic headings ("Section 1"); labels like "Field"; a heading that doesn't match
the content beneath it.
**Fix:** write descriptive, specific headings and labels.

### 2.4.7 Focus Visible — AA
**Requires:** the keyboard focus indicator is visible.
**Common failures:** `outline: none` / `outline: 0` with no replacement; a custom focus style with
< 3:1 contrast (also fails 1.4.11); focus indicator only on some interactive elements.
**How to check:** Tab through; every focusable element must show a clearly visible indicator.
**Fix:** never remove the outline without replacing it. Prefer a global `:focus-visible` style so every
control is covered:
```css
:focus-visible { outline: 2px solid <accent>; outline-offset: 2px; }
```
Removing the default outline *and* not providing a replacement is one of the most common serious
failures. *Optional 2.2 enhancement: 2.4.13 Focus Appearance defines a stronger indicator (AAA).*

### 2.4.11 Focus Not Obscured (Minimum) — AA 🆕
**Requires:** when an element receives focus, it is not *entirely* hidden by author-created content.
**Common failures:** a sticky header/footer, cookie banner, or floating chat widget covering the
focused element as you Tab down; a focused item scrolled fully behind a sticky bar.
**How to check:** Tab through a page with sticky/fixed overlays; watch for the focused control
disappearing behind them.
**Fix:** add `scroll-margin` so focused elements clear sticky bars; ensure overlays don't cover the
focused control; use `scroll-padding-top` on the scroll container to account for a sticky header.

### 2.5.1 Pointer Gestures — A
**Requires:** any multipoint or path-based gesture (pinch, two-finger, swipe-path) has a single-pointer
alternative.
**Common failures:** functionality only reachable via swipe or pinch with no button/tap equivalent.
**Fix:** provide tap/click controls that do the same thing (e.g. prev/next buttons alongside a swipe
carousel).

### 2.5.2 Pointer Cancellation — A
**Requires:** for single-pointer actions, completion is on the **up** event, or the action can be
aborted/undone. (Prevents accidental activation.)
**Common failures:** firing an irreversible action on `mousedown`/`touchstart`.
**Fix:** trigger on `click`/`pointerup`, not `pointerdown`; allow moving the pointer off the target
before release to cancel.

### 2.5.3 Label in Name — A
**Requires:** a control's accessible name **contains** its visible label text (so speech-input users who
say the visible words can activate it).
**Common failures:** a button visibly reading "Submit" but with `aria-label="Send form"` — the visible
word "Submit" isn't in the accessible name; an icon+text button whose `aria-label` omits the visible text.
**How to check:** does the accessible name include the visible text string?
**Fix:** make the accessible name start with / include the visible label. Best: let the visible text
*be* the name (don't override with `aria-label` unless it fully contains the visible text).

### 2.5.7 Dragging Movements — AA 🆕
**Requires:** any action using a dragging movement has a single-pointer alternative that isn't a drag
(unless dragging is essential).
**Common failures:** drag-only reordering, drag-only sliders, drag-to-dismiss with no button.
**Fix:** add click/tap alternatives — up/down buttons for reordering, a click-track for a slider, a
close button for drag-to-dismiss.

### 2.5.8 Target Size (Minimum) — AA 🆕
**Requires:** pointer targets are at least **24×24 CSS px**, *or* have ≥ 24px spacing to neighboring
targets (measured as a 24px circle not overlapping another target). Exceptions: inline links within a
sentence, targets whose size is UA-determined, or an equivalent control elsewhere on the page.
**Common failures:** tiny icon buttons (16px); cramped icon rows; a small close "×"; dense controls
packed together.
**How to check:** measure the clickable box (padding counts). Under 24px and not spaced/exempt → fail.
**Fix:** pad the hit area to ≥ 24px (a small glyph can keep its size while padding grows the target), or
add spacing between adjacent controls.
```css
.icon-btn { min-width: 24px; min-height: 24px; padding: 8px; }
```
*Optional AAA (2.5.5): 44×44px — a good target for standalone controls (matches mobile HIG). The
inline-cluster exception still applies: a tightly-wrapped row of text links may sit at the 24px AA
minimum on purpose; don't force it to 44.*

---

## Understandable

### 3.2.1 On Focus — A
**Requires:** moving focus to a component does not trigger an unexpected change of context (navigation,
new window, major layout change, form submit).
**Common failures:** a `<select>` that navigates `onfocus`; a field that submits when focused.
**Fix:** trigger context changes on explicit activation, not on focus.

### 3.2.2 On Input — A
**Requires:** changing a setting (typing, selecting) doesn't cause an unexpected context change unless
the user was told it would.
**Common failures:** a `<select>` that auto-navigates `onchange` with no "Go" button; a form that
auto-submits when the last field changes.
**Fix:** require an explicit submit/confirm action, or clearly warn beforehand.

### 3.3.1 Error Identification — A
**Requires:** input errors are identified and described **in text** (not color/icon alone).
**Common failures:** field turns red with no message; a generic "Error" with no specifics; error not
associated with its field.
**Fix:** show a text message, tie it to the field with `aria-describedby`, and mark the field
`aria-invalid="true"`.
```html
<label for="email">Email</label>
<input id="email" type="email" aria-invalid="true" aria-describedby="email-err">
<p id="email-err" class="error">Enter a valid email, e.g. name@example.com</p>
```

### 3.3.2 Labels or Instructions — A
**Requires:** provide labels or instructions when content requires user input.
**Common failures:** placeholder-as-label (vanishes on typing, low contrast, not a real label); no
format hint for constrained input (date, password rules); required fields not indicated.
**Fix:** every field has a persistent `<label>`; give format hints via `aria-describedby`; mark required
fields in text (and `required`/`aria-required`).

### 3.3.3 Error Suggestion — AA
**Requires:** if an input error is detected and a correction is known, suggest it (unless it'd
compromise security).
**Common failures:** "Invalid input" with no hint how to fix; a date rejected with no expected format.
**Fix:** name the expected format / valid values ("Use MM/DD/YYYY", "Password needs 8+ characters and a
number").

---

## Robust

### 4.1.2 Name, Role, Value — A
**Requires:** every UI component exposes an accessible **name**, a **role**, and its current
**states/values** to assistive tech — and updates them as they change.
**Common failures:** `<div>`/`<span>` widgets with no role; a custom checkbox/toggle/tab with no
`aria-checked`/`aria-selected`/`aria-expanded`; a control with no accessible name; state that changes
visually but not programmatically (e.g. an accordion whose `aria-expanded` never flips).
**How to check:** inspect the accessibility tree (browser devtools → Accessibility pane) or use a
screen reader — every control should announce name + role + state, and state should update on
interaction.
**Fix:** use native elements first (they carry all three). For custom widgets, follow the matching
[ARIA APG pattern](https://www.w3.org/WAI/ARIA/apg/patterns/): set `role`, provide a name (visible
label, `aria-label`, or `aria-labelledby`), and keep state attributes in sync with JS.
```html
<button aria-expanded="false" aria-controls="panel1">Details</button>
<div id="panel1" hidden>…</div>
<!-- JS flips aria-expanded and toggles [hidden] together -->
```

### 4.1.3 Status Messages — AA
**Requires:** status messages (success, error count, "3 results found", "saved", loading) can be
announced by assistive tech **without** moving focus.
**Common failures:** a toast/inline confirmation added to the DOM that a screen-reader user never
hears; search-results count updated silently; a form-error summary not announced.
**Fix:** put the message in a live region.
```html
<div role="status" aria-live="polite">Draft saved</div>            <!-- non-urgent -->
<div role="alert" aria-live="assertive">3 errors in the form</div>  <!-- urgent -->
```
The live-region container must exist in the DOM *before* you inject text into it (screen readers watch
it for changes); creating the element and its text at once may not announce.

---

## Quick triage map — "if the code has… check…"

| The UI contains… | Check these SC |
|---|---|
| Images / icons / SVG | 1.1.1, 1.4.11 |
| Text | 1.4.3, 1.4.1, 1.4.12 |
| Color-coded state/meaning | 1.4.1, 1.4.11 |
| A form / inputs | 1.3.1, 1.3.5, 3.3.1, 3.3.2, 3.3.3, 2.5.3, 4.1.2 |
| Links | 2.4.4, 2.5.3 |
| Headings / page structure | 1.3.1, 2.4.6 |
| Data table | 1.3.1 |
| Custom interactive widget (`<div>`-based) | 2.1.1, 2.1.2, 2.4.3, 2.4.7, 4.1.2 |
| Buttons / clickable controls | 2.1.1, 2.5.8, 2.5.2, 4.1.2, 2.4.7 |
| Modal / popover / tooltip | 2.1.2, 1.4.13, 2.4.11, 4.1.2 |
| Drag / swipe / pinch interaction | 2.5.1, 2.5.7 |
| Auto-updating content / toasts / results count | 4.1.3, 3.2.2 |
| Keyboard shortcuts | 2.1.4 |
| Responsive / zoom concerns | 1.4.10, 1.4.4, 1.4.12 |
| Sticky/fixed overlays | 2.4.11 |

reference/verification-guide.md

# Verification guide — how to actually test, not guess

Accessibility findings must be *verified*, not eyeballed. This guide covers the four manual passes and
the contrast math. A claimed ratio with no calculation, or a "looks reachable" with no keyboard walk,
is not a finding.

Order of effort: automated scan (cheap, catches ~30–40%) → keyboard → screen reader → contrast/zoom.
The automated scan never stands alone — most real barriers are only found by the manual passes.

---

## 1. Contrast ratio — compute it, don't eyeball

WCAG contrast uses **relative luminance**. The ratio is `(L_lighter + 0.05) / (L_darker + 0.05)`,
where each color's luminance is computed from its linearized sRGB channels.

Node snippet (drop-in — pass two hex colors):

```js
function luminance(hex) {
  const c = hex.replace('#', '');
  const [r, g, b] = [0, 2, 4].map(i => parseInt(c.slice(i, i + 2), 16) / 255)
    .map(v => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)));
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function ratio(fg, bg) {
  const [a, b] = [luminance(fg), luminance(bg)].sort((x, y) => y - x);
  return (a + 0.05) / (b + 0.05);
}
// ratio('#767676', '#ffffff') -> 4.54  (passes AA for normal text)
```

**Thresholds:**
- Normal text: **4.5:1** (AA) · 7:1 (AAA)
- Large text (≥ 24px, or ≥ 18.66px bold): **3:1** (AA) · 4.5:1 (AAA)
- UI component boundaries + meaningful graphics + focus indicators: **3:1** (AA, SC 1.4.11)

### The `opacity` trap (most-missed contrast bug)
Dimming text with `opacity` (or a semi-transparent color) does **not** keep its contrast — the color
the eye sees is the foreground **blended over the background** at that alpha. Check the *blended*
color, not the original.

```js
// blend fg over bg at alpha a, per channel, then feed the result to ratio()
function blend(fgHex, bgHex, a) {
  const hex = h => [0, 2, 4].map(i => parseInt(h.replace('#','').slice(i, i+2), 16));
  const [f, b] = [hex(fgHex), hex(bgHex)];
  const mix = f.map((fc, i) => Math.round(fc * a + b[i] * (1 - a)));
  return '#' + mix.map(x => x.toString(16).padStart(2, '0')).join('');
}
// text #333 at opacity .6 on white: ratio(blend('#333333','#ffffff',0.6), '#ffffff')
```
A color that passes at full opacity can fail once dimmed. **Rule: never use `opacity` to make text
"secondary."** Pick a color that passes at full strength.

### Text on images / gradients
There's no single background color. Sample the *lightest* region behind light text (or darkest behind
dark text) — the worst case — or add a solid/scrim layer so there's a known background to measure.

---

## 2. Keyboard pass — unplug the mouse

Tab through the whole UI and confirm:
- **Reachable:** every interactive element receives focus (nothing skipped, nothing mouse-only).
- **Operable:** Enter/Space activate buttons; arrow keys drive composite widgets (menus, tabs,
  radios, sliders) per the [ARIA APG](https://www.w3.org/WAI/ARIA/apg/patterns/); Esc closes
  dismissible things.
- **Visible:** a clear focus indicator on every stop (SC 2.4.7), and it stays ≥ 3:1 (SC 1.4.11).
- **Logical order:** focus moves in the visual reading order (SC 2.4.3) — watch for CSS reordering
  (flex/grid `order`, absolute positioning) desyncing DOM order from visual order.
- **No trap:** you can Tab back out of everything (SC 2.1.2).
- **Not obscured:** the focused element isn't hidden behind a sticky header/footer/overlay (SC 2.4.11).
- **Modals:** focus moves in on open, is contained while open, Esc closes, and focus returns to the
  trigger on close.

Anything unreachable or unoperable by keyboard is a **Blocker** — keyboard access is the floor for
switch users, screen-reader users, and many motor-impaired users.

---

## 3. Screen-reader pass

Test with at least one real screen reader — **VoiceOver** (macOS: Cmd+F5), **NVDA** (Windows, free),
or **Narrator**. Browser devtools' **Accessibility tree** pane is a fast proxy for name/role/value.

Confirm:
- **Name:** every control announces a meaningful accessible name (SC 4.1.2, 1.1.1). Icon-only controls
  aren't silent.
- **Role:** the announced role matches behavior — a thing that acts like a button *is* a button.
- **State/value:** toggles/expanders/selected states announce and *update* on interaction
  (`aria-expanded`, `aria-checked`, `aria-selected`).
- **Structure:** heading navigation (H key in NVDA/VO) reflects the real outline; landmarks
  (`main`, `nav`, …) let you jump around; lists announce item counts.
- **Forms:** each field announces its label, required state, and — on error — the error text
  (SC 3.3.1); errors are tied via `aria-describedby`.
- **Live updates:** toasts, result counts, and async status announce without stealing focus
  (SC 4.1.3) — verify the `aria-live`/`role="status"` region actually speaks.
- **No noise:** decorative images are silent (`alt=""`/`aria-hidden`); ARIA isn't duplicating what
  native semantics already say.

---

## 4. Zoom / reflow / spacing pass

- **Reflow (SC 1.4.10):** set the browser to 400% zoom (or a 320px-wide viewport). Content should
  reflow to one column with **no horizontal scroll** and nothing clipped/overlapping. Genuinely-wide
  content (tables, code, maps) may scroll within its own container.
- **Resize text (SC 1.4.4):** zoom text to 200%; nothing is clipped or lost.
- **Text spacing (SC 1.4.12):** apply the user-override spacing (line-height 1.5, paragraph 2×,
  letter 0.12em, word 0.16em) via a bookmarklet/devtools; confirm no clipping in fixed-height boxes.
- **Common culprit:** a flex/grid child that won't shrink causes overflow — give it `min-width: 0`.

---

## 5. Multi-theme / multi-mode

If the UI ships more than one theme (light/dark/high-contrast), RTL, or density modes, **re-run the
contrast and non-text-contrast checks in each** — the palette changes per mode, so a pass in one is not
a pass in another. This is the single most common source of "we fixed contrast" regressions: the fix
was verified in one theme only.

---

## What "done" looks like

Every reported violation has: the SC number, the `file:line`, the concrete user impact, a **computed**
value (for contrast/size) or a described repro (for keyboard/SR), and a corrected code snippet. If you
couldn't verify something (e.g. no screen reader available), say so explicitly rather than asserting a
pass.
AccessibilityClaude Code
Search

Loading search…