# Accessibility Audit

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

- Skill: `jaykim88/accessibility-audit` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jaykim88/accessibility-audit`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jaykim88/accessibility-audit/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Design & Media
- License: MIT
- Author: JayKim88 (https://skillmd.com/u/jaykim88)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jaykim88/accessibility-audit

---


# 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

1. **Semantic HTML — replace generic with specific**
   - `<div onClick>` → `<button>` (focusable, keyboard-accessible, screen-reader-recognized by default)
   - Links for navigation: `<a href>` not `<button onClick={() => 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)

2. **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

3. **Verify heading hierarchy**
   - `h1` → `h2` → `h3` (no skipping levels)
   - One `h1` per page
   - Audit with browser extension (HeadingsMap) or axe-core

4. **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

5. **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)

6. **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"`

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

8. **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

9. **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.

10. **Manual screen reader check**
    - macOS: VoiceOver (Cmd+F5)
    - Walk through critical flows
    - Verify announcements make sense

## Before / After

**Interactive element semantics**

```jsx
// ❌ Not focusable, not keyboard-accessible, screen readers don't announce it as interactive
<div onClick={handleSave} className="cursor-pointer">Save</div>

// ✅ Focusable by default, Enter/Space activates, announced as "button"
<button onClick={handleSave} type="button">Save</button>
```

**Focus indicator removal**

```css
/* ❌ 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 onClick={handler}>` | `<button onClick={handler}>` (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
- [ ] axe-core violations = 0 (WCAG 2.2 AA tags); `eslint-plugin-jsx-a11y` clean
- [ ] Lighthouse Accessibility ≥ 95
- [ ] Full keyboard navigation tested
- [ ] No `outline: none` without replacement focus indicator; focus not obscured by sticky UI (2.4.11)
- [ ] Meaning never conveyed by color alone (1.4.1)
- [ ] Skip link present; `<html lang>` set
- [ ] Pointer targets ≥ 24px (2.5.8); usable at 200% zoom / 320px reflow
- [ ] `prefers-reduced-motion` honored
- [ ] Color contrast verified in light and dark mode
- [ ] All Critical findings fixed; all Major findings scheduled

## 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).

