Accessibility Audit
Purpose
Meet WCAG 2.2 AA (the current W3C standard — 2.2 adds target size, dragging alternatives, and focus-not-obscured on top of 2.1). Automated tools catch ~57% of issues (Deque 2021 study) — combine static lint + axe-core with manual keyboard testing and screen reader sanity checks.
Universal — WCAG 2.2 AA, axe-core, semantic HTML rules, and keyboard navigation requirements apply to every web framework. Only the syntax for ARIA attributes and motion-reduce variants differs by template language.
Procedure
Semantic HTML — replace generic with specific
<div onClick> → <button> (focusable, keyboard-accessible, screen-reader-recognized by default)
- Links for navigation:
<a href> not <button => router.push()}>
- Landmarks:
<main>, <nav>, <header>, <footer>, <aside> (instead of generic <div>)
- Bypass blocks: a "skip to main content" link as the first focusable element (WCAG 2.4.1)
- Set
<html lang> so screen readers use the correct pronunciation (WCAG 3.1.1)
Audit images
- Every
<img> has alt attribute
- Decorative images:
alt="" (explicit empty, screen reader skips)
- Informational images: descriptive
alt
next/image enforces alt — keep it lint-enforced
Verify heading hierarchy
h1 → h2 → h3 (no skipping levels)
- One
h1 per page
- Audit with browser extension (HeadingsMap) or axe-core
Color contrast checks
- Body text: 4.5:1 ratio minimum
- Large text (18pt+ or 14pt+ bold): 3:1 minimum
- Verify in BOTH light and dark mode
- Don't convey meaning by color alone (WCAG 1.4.1) — error / required / status needs an icon, text, or pattern too, not just red/green
- Tools: Stark, Lighthouse, axe DevTools
Keyboard navigation — manual walkthrough
- Tab through the entire page
- Verify: focus order matches visual order, every interactive element reachable
- Test: Escape closes modals, Arrow keys navigate menus/lists, Enter/Space activate buttons
- Verify focus indicators are visible (no
outline: none without replacement)
Focus management for dynamic UI
- Modal open: move focus inside the modal (trap focus)
- Modal close: return focus to the trigger
- Route change: announce new page (e.g., focus the
h1 or use aria-live)
- Loading states:
aria-busy or aria-live="polite"
ARIA where semantic HTML doesn't suffice
- Custom components:
role, aria-label, aria-describedby, aria-expanded, aria-controls
- Icon-only controls need an accessible name:
aria-label on an icon <button> with no visible text (the inverse of the anti-pattern — text buttons need none)
- Live regions:
aria-live="polite" for non-urgent updates, assertive for critical
- Form inputs: verify input purpose is set (
autocomplete, WCAG 1.3.5) and errors are programmatically identified (aria-invalid + message, WCAG 3.3.1) — build pattern lives in form-ux
- Audit:
grep -rn 'aria-' src/ and verify correctness
Reduced motion
- Audit animations for
prefers-reduced-motion respect
- Tailwind:
motion-reduce: variant
- Coordinate with
animation-quality skill
8b. Pointer, zoom & WCAG 2.2 checks
- Target size ≥ 24×24 CSS px for pointer targets, or sufficient spacing (WCAG 2.2 §2.5.8) — coordinate with
responsive-design touch targets (44/48px is stricter)
- Dragging alternative (WCAG 2.2 §2.5.7): any drag operation also works with a single tap/click
- Focus not obscured (WCAG 2.2 §2.4.11): a focused element isn't fully hidden behind sticky headers/footers
- Zoom & reflow: usable at 200% zoom and reflows to a 320px viewport with no horizontal scroll or lost content (WCAG 1.4.4 / 1.4.10)
- Accessible authentication (WCAG 2.2 §3.3.8): don't require solving puzzles or memorization; allow paste into password fields
Automated audit (validation loop)
- Static lint first:
eslint-plugin-jsx-a11y catches missing alt, invalid ARIA, and handlers on non-interactive elements at dev time — cheaper than runtime axe
- Run axe-core with WCAG 2.2 AA tag filter (
tags: ['wcag2a','wcag2aa','wcag21aa','wcag22aa'])
- If violations > 0: fix each one (prioritize Critical → Major → Minor) and re-run until 0
- Run Lighthouse Accessibility audit; if score < 95, address findings and re-run until ≥ 95
- See
cicd-pipeline skill for wiring lint + axe into the GitHub Actions matrix as a blocking step.
Manual screen reader check
- macOS: VoiceOver (Cmd+F5)
- Walk through critical flows
- Verify announcements make sense
Before / After
Interactive element semantics
// ❌ Not focusable, not keyboard-accessible, screen readers don't announce it as interactive
<div className="cursor-pointer">Save</div>
// ✅ Focusable by default, Enter/Space activates, announced as "button"
<button type="button">Save</button>
Focus indicator removal
/* ❌ Strips focus indicator without replacement — keyboard users lost */
*:focus { outline: none; }
/* ✅ Custom focus ring with sufficient contrast */
*:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
Anti-patterns
| ❌ Anti-pattern |
✅ Correct |
<div> |
<button> (focusable, keyboard-accessible by default) |
outline: none without replacement |
Custom focus ring with sufficient contrast (focus-visible:ring-2) |
tabindex="0" on everything |
Only interactive elements need focus order |
aria-label on a <button>Submit</button> |
Button text is the label; no aria-label needed |
Icon-only <button> with no text or aria-label |
<button aria-label="Close">✕</button> |
| Error shown only by red color |
Color + icon/text (don't rely on color alone, 1.4.1) |
h1 → h3 skipping h2 |
Sequential heading levels (h1 → h2 → h3) |
<img src="logo.png"> with no alt |
<img alt="Company Name logo"> or alt="" for decorative |
Severity tiers
| Tier |
Examples |
Action SLA |
| Critical |
Keyboard-trap (modal can't be closed via keyboard); image with no alt blocking core flow; color contrast < 3:1 for body text; icon-only control with no accessible name on a core action |
Block release; fix immediately |
| Major |
Missing focus indicator on interactive elements; heading-skip violations; missing aria-describedby on form errors; meaning conveyed by color alone (1.4.1); no skip link; focused element obscured by sticky header (2.4.11) |
Fix this sprint |
| Minor |
Decorative images missing explicit alt=""; pointer target < 24px (2.5.8); missing <html lang>; non-critical aria-label improvements |
Schedule within 2 sprints |
Completion Criteria
Stop & Ask (AI must pause for user approval)
- Before bulk-renaming semantic markup across 5+ components (e.g.,
<div onClick> → <button> everywhere) — verify each replacement preserves layout and event semantics
- Before changing focus management in modals/dialogs — wrong focus trap can break UX
- Before adding ARIA roles to existing custom components — wrong role can mislead screen readers; verify with manual screen reader test first
Output
- Report:
docs/a11y-audit-YYYY-MM-DD.md with sections:
## Summary — axe-core violations / Lighthouse score / manual findings
## Critical findings — per finding: file:line, WCAG criterion (e.g., 1.4.3 Contrast), screen reader impact, fix
## Major findings — same format
## Minor findings — same format
## Manual testing notes — keyboard nav, screen reader walkthrough results
- Code changes: semantic markup + ARIA fixes; commit format
fix(a11y): <description> [WCAG-N.N.N]
- CI integration: axe-core blocking step in pipeline (see
cicd-pipeline skill)
Implementation
React + Next.js (default)
- Semantic JSX:
<button>, <nav>, <main>, <article> (no <div onClick>)
- ARIA:
aria-label, aria-describedby, aria-live, aria-expanded as JSX props
- Reduced motion: Tailwind
motion-reduce: variant or CSS @media (prefers-reduced-motion: reduce)
- Static lint:
eslint-plugin-jsx-a11y (ships in eslint-config-next) — enable the strict ruleset
- Focus ring:
focus-visible:ring-2 ring-offset-2 (visible, sufficient size/contrast)
- Testing:
@axe-core/react (dev) + axe-core/playwright (CI)
- Storybook a11y addon for component-level checks
Other stacks
- Vue / Nuxt: same semantic HTML; ARIA as kebab-case attrs (
aria-label="..."); reduced motion via Tailwind or CSS @media
- SvelteKit: same; Svelte has built-in a11y warnings in dev (
<div on:click> → warning)
- Angular: same semantic HTML; ARIA as attribute bindings (
[attr.aria-label]="..."); Angular CDK provides FocusTrap, LiveAnnouncer
- All stacks: axe-core works framework-agnostic; Lighthouse Accessibility audits any served HTML; manual keyboard + screen reader testing identical
- WCAG 2.2 AA criteria — framework-independent; the universal procedure stands alone
Related skills
design-system-construction — a11y must be baked into shared components, not retrofitted
animation-quality — prefers-reduced-motion respect is enforced jointly
form-ux — label/aria-describedby, autocomplete (1.3.5), error identification (3.3.1) build patterns
responsive-design — touch-target size and reflow overlap with WCAG 2.5.8 / 1.4.10
cicd-pipeline — wire eslint-plugin-jsx-a11y + axe-core into the GitHub Actions matrix
Reference
- Key insight encoded: Lint statically first (
eslint-plugin-jsx-a11y), then gate violations in CI with axe tags: ['wcag2a','wcag2aa','wcag21aa','wcag22aa']. Automation catches only ~57% of issues — always pair with manual keyboard traversal and at least one screen reader sanity check before shipping. Semantic HTML is the cheapest accessibility tool: <button> is better than <div onClick> + 5 ARIA attributes. Target WCAG 2.2 AA (current standard): beyond 2.1, that means ≥24px target size (2.5.8), focus not obscured (2.4.11), and a non-drag alternative (2.5.7).
1---2name: accessibility-audit3description: WCAG 2.2 AA audit — semantic HTML, axe-core + eslint-plugin-jsx-a11y, keyboard navigation, ARIA, color contrast, target size, focus-not-obscured, prefers-reduced-motion. Use when completing a new component, after design system changes, or before shipping. Not for form label/aria-describedby patterns (use form-ux) or baking a11y into shared components from the start (use design-system-construction).4license: MIT5---67# Accessibility Audit89## Purpose10Meet WCAG 2.2 AA (the current W3C standard — 2.2 adds target size, dragging alternatives, and focus-not-obscured on top of 2.1). Automated tools catch ~57% of issues (Deque 2021 study) — combine static lint + axe-core with manual keyboard testing and screen reader sanity checks.1112**Universal** — WCAG 2.2 AA, axe-core, semantic HTML rules, and keyboard navigation requirements apply to every web framework. Only the syntax for ARIA attributes and motion-reduce variants differs by template language.1314## Procedure15161. **Semantic HTML — replace generic with specific**17 - `<div onClick>` → `<button>` (focusable, keyboard-accessible, screen-reader-recognized by default)18 - Links for navigation: `<a href>` not `<button onClick={() => router.push()}>`19 - Landmarks: `<main>`, `<nav>`, `<header>`, `<footer>`, `<aside>` (instead of generic `<div>`)20 - Bypass blocks: a "skip to main content" link as the first focusable element (WCAG 2.4.1)21 - Set `<html lang>` so screen readers use the correct pronunciation (WCAG 3.1.1)22232. **Audit images**24 - Every `<img>` has `alt` attribute25 - Decorative images: `alt=""` (explicit empty, screen reader skips)26 - Informational images: descriptive `alt`27 - `next/image` enforces `alt` — keep it lint-enforced28293. **Verify heading hierarchy**30 - `h1` → `h2` → `h3` (no skipping levels)31 - One `h1` per page32 - Audit with browser extension (HeadingsMap) or axe-core33344. **Color contrast checks**35 - Body text: 4.5:1 ratio minimum36 - Large text (18pt+ or 14pt+ bold): 3:1 minimum37 - Verify in BOTH light and dark mode38 - **Don't convey meaning by color alone** (WCAG 1.4.1) — error / required / status needs an icon, text, or pattern too, not just red/green39 - Tools: Stark, Lighthouse, axe DevTools40415. **Keyboard navigation — manual walkthrough**42 - Tab through the entire page43 - Verify: focus order matches visual order, every interactive element reachable44 - Test: Escape closes modals, Arrow keys navigate menus/lists, Enter/Space activate buttons45 - Verify focus indicators are visible (no `outline: none` without replacement)46476. **Focus management for dynamic UI**48 - Modal open: move focus inside the modal (trap focus)49 - Modal close: return focus to the trigger50 - Route change: announce new page (e.g., focus the `h1` or use `aria-live`)51 - Loading states: `aria-busy` or `aria-live="polite"`52537. **ARIA where semantic HTML doesn't suffice**54 - Custom components: `role`, `aria-label`, `aria-describedby`, `aria-expanded`, `aria-controls`55 - Icon-only controls need an accessible name: `aria-label` on an icon `<button>` with no visible text (the inverse of the anti-pattern — text buttons need none)56 - Live regions: `aria-live="polite"` for non-urgent updates, `assertive` for critical57 - Form inputs: verify input purpose is set (`autocomplete`, WCAG 1.3.5) and errors are programmatically identified (`aria-invalid` + message, WCAG 3.3.1) — build pattern lives in `form-ux`58 - Audit: `grep -rn 'aria-' src/` and verify correctness59608. **Reduced motion**61 - Audit animations for `prefers-reduced-motion` respect62 - Tailwind: `motion-reduce:` variant63 - Coordinate with `animation-quality` skill64658b. **Pointer, zoom & WCAG 2.2 checks**66 - **Target size** ≥ 24×24 CSS px for pointer targets, or sufficient spacing (WCAG 2.2 §2.5.8) — coordinate with `responsive-design` touch targets (44/48px is stricter)67 - **Dragging alternative** (WCAG 2.2 §2.5.7): any drag operation also works with a single tap/click68 - **Focus not obscured** (WCAG 2.2 §2.4.11): a focused element isn't fully hidden behind sticky headers/footers69 - **Zoom & reflow**: usable at 200% zoom and reflows to a 320px viewport with no horizontal scroll or lost content (WCAG 1.4.4 / 1.4.10)70 - **Accessible authentication** (WCAG 2.2 §3.3.8): don't require solving puzzles or memorization; allow paste into password fields71729. **Automated audit (validation loop)**73 - Static lint first: `eslint-plugin-jsx-a11y` catches missing `alt`, invalid ARIA, and handlers on non-interactive elements at dev time — cheaper than runtime axe74 - Run axe-core with WCAG 2.2 AA tag filter (`tags: ['wcag2a','wcag2aa','wcag21aa','wcag22aa']`)75 - If violations > 0: fix each one (prioritize Critical → Major → Minor) and re-run until 076 - Run Lighthouse Accessibility audit; if score < 95, address findings and re-run until ≥ 9577 - See `cicd-pipeline` skill for wiring lint + axe into the GitHub Actions matrix as a blocking step.787910. **Manual screen reader check**80 - macOS: VoiceOver (Cmd+F5)81 - Walk through critical flows82 - Verify announcements make sense8384## Before / After8586**Interactive element semantics**8788```jsx89// ❌ Not focusable, not keyboard-accessible, screen readers don't announce it as interactive90<div onClick={handleSave} className="cursor-pointer">Save</div>9192// ✅ Focusable by default, Enter/Space activates, announced as "button"93<button onClick={handleSave} type="button">Save</button>94```9596**Focus indicator removal**9798```css99/* ❌ Strips focus indicator without replacement — keyboard users lost */100*:focus { outline: none; }101102/* ✅ Custom focus ring with sufficient contrast */103*:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }104```105106## Anti-patterns107108| ❌ Anti-pattern | ✅ Correct |109|---|---|110| `<div onClick={handler}>` | `<button onClick={handler}>` (focusable, keyboard-accessible by default) |111| `outline: none` without replacement | Custom focus ring with sufficient contrast (`focus-visible:ring-2`) |112| `tabindex="0"` on everything | Only interactive elements need focus order |113| `aria-label` on a `<button>Submit</button>` | Button text *is* the label; no `aria-label` needed |114| Icon-only `<button>` with no text or `aria-label` | `<button aria-label="Close">✕</button>` |115| Error shown only by red color | Color + icon/text (don't rely on color alone, 1.4.1) |116| `h1` → `h3` skipping `h2` | Sequential heading levels (h1 → h2 → h3) |117| `<img src="logo.png">` with no alt | `<img alt="Company Name logo">` or `alt=""` for decorative |118119## Severity tiers120121| Tier | Examples | Action SLA |122|---|---|---|123| **Critical** | Keyboard-trap (modal can't be closed via keyboard); image with no alt blocking core flow; color contrast < 3:1 for body text; icon-only control with no accessible name on a core action | Block release; fix immediately |124| **Major** | Missing focus indicator on interactive elements; heading-skip violations; missing aria-describedby on form errors; meaning conveyed by color alone (1.4.1); no skip link; focused element obscured by sticky header (2.4.11) | Fix this sprint |125| **Minor** | Decorative images missing explicit `alt=""`; pointer target < 24px (2.5.8); missing `<html lang>`; non-critical aria-label improvements | Schedule within 2 sprints |126127## Completion Criteria128- [ ] axe-core violations = 0 (WCAG 2.2 AA tags); `eslint-plugin-jsx-a11y` clean129- [ ] Lighthouse Accessibility ≥ 95130- [ ] Full keyboard navigation tested131- [ ] No `outline: none` without replacement focus indicator; focus not obscured by sticky UI (2.4.11)132- [ ] Meaning never conveyed by color alone (1.4.1)133- [ ] Skip link present; `<html lang>` set134- [ ] Pointer targets ≥ 24px (2.5.8); usable at 200% zoom / 320px reflow135- [ ] `prefers-reduced-motion` honored136- [ ] Color contrast verified in light and dark mode137- [ ] All Critical findings fixed; all Major findings scheduled138139## Stop & Ask (AI must pause for user approval)140141- **Before bulk-renaming semantic markup** across 5+ components (e.g., `<div onClick>` → `<button>` everywhere) — verify each replacement preserves layout and event semantics142- **Before changing focus management** in modals/dialogs — wrong focus trap can break UX143- **Before adding ARIA roles to existing custom components** — wrong role can mislead screen readers; verify with manual screen reader test first144145## Output146- **Report**: `docs/a11y-audit-YYYY-MM-DD.md` with sections:147 - `## Summary` — axe-core violations / Lighthouse score / manual findings148 - `## Critical findings` — per finding: file:line, WCAG criterion (e.g., `1.4.3 Contrast`), screen reader impact, fix149 - `## Major findings` — same format150 - `## Minor findings` — same format151 - `## Manual testing notes` — keyboard nav, screen reader walkthrough results152- **Code changes**: semantic markup + ARIA fixes; commit format `fix(a11y): <description> [WCAG-N.N.N]`153- **CI integration**: axe-core blocking step in pipeline (see `cicd-pipeline` skill)154155## Implementation156157### React + Next.js (default)158- Semantic JSX: `<button>`, `<nav>`, `<main>`, `<article>` (no `<div onClick>`)159- ARIA: `aria-label`, `aria-describedby`, `aria-live`, `aria-expanded` as JSX props160- Reduced motion: Tailwind `motion-reduce:` variant or CSS `@media (prefers-reduced-motion: reduce)`161- Static lint: `eslint-plugin-jsx-a11y` (ships in `eslint-config-next`) — enable the `strict` ruleset162- Focus ring: `focus-visible:ring-2 ring-offset-2` (visible, sufficient size/contrast)163- Testing: `@axe-core/react` (dev) + `axe-core/playwright` (CI)164- Storybook a11y addon for component-level checks165166### Other stacks167- **Vue / Nuxt**: same semantic HTML; ARIA as kebab-case attrs (`aria-label="..."`); reduced motion via Tailwind or CSS `@media`168- **SvelteKit**: same; Svelte has built-in a11y warnings in dev (`<div on:click>` → warning)169- **Angular**: same semantic HTML; ARIA as attribute bindings (`[attr.aria-label]="..."`); Angular CDK provides `FocusTrap`, `LiveAnnouncer`170- **All stacks**: axe-core works framework-agnostic; Lighthouse Accessibility audits any served HTML; manual keyboard + screen reader testing identical171- **WCAG 2.2 AA criteria** — framework-independent; the universal procedure stands alone172173## Related skills174- `design-system-construction` — a11y must be baked into shared components, not retrofitted175- `animation-quality` — prefers-reduced-motion respect is enforced jointly176- `form-ux` — label/aria-describedby, autocomplete (1.3.5), error identification (3.3.1) build patterns177- `responsive-design` — touch-target size and reflow overlap with WCAG 2.5.8 / 1.4.10178- `cicd-pipeline` — wire eslint-plugin-jsx-a11y + axe-core into the GitHub Actions matrix179180## Reference181- **Key insight encoded**: Lint statically first (`eslint-plugin-jsx-a11y`), then gate violations in CI with axe `tags: ['wcag2a','wcag2aa','wcag21aa','wcag22aa']`. Automation catches only ~57% of issues — always pair with manual keyboard traversal and at least one screen reader sanity check before shipping. Semantic HTML is the cheapest accessibility tool: `<button>` is better than `<div onClick>` + 5 ARIA attributes. Target WCAG 2.2 AA (current standard): beyond 2.1, that means ≥24px target size (2.5.8), focus not obscured (2.4.11), and a non-drag alternative (2.5.7).