---
name: web-accessibility-wcag
description: Ship interactive UI to WCAG 2.1 AA, keyboard operability, focus management, ARIA name/role/state, and contrast, built on Radix primitive patterns. Use when building or reviewing dialogs, menus, tabs, comboboxes, tooltips, and forms, or when a surface must work without a mouse. Not for general stack conventions: use ui-engineering-stack-rules. Not for browser-matrix verification runs: use browser-visual-qa.
license: MIT
metadata:
  author: TechTide AI (Alex Cinovoj)
  provenance: rewritten from patterns in radix-ui/primitives (MIT)
  category: Quality & Security
---

# Web Accessibility (WCAG 2.1 AA)

Accessibility is the interaction contract of the component, not a lint pass at the end. The operating rule: reach for a primitive that already owns the behavior (Radix implements the ARIA Authoring Practices keyboard and focus models) instead of hand-rolling `role`, `aria-*`, and key handlers. Hand-rolled widgets are where generated UI silently breaks screen readers.

## The four load-bearing areas

### 1. Keyboard operability (2.1.1)

Every pointer action must be reachable by keyboard, with the expected key model:

- Tab / Shift+Tab moves between composite widgets, not within them.
- Arrow keys move within a composite (menu items, tabs, radios, listbox options).
- Enter / Space activate. Escape dismisses overlays and returns focus to the trigger.
- Home / End jump to first and last in long lists.

The pattern behind this is roving tabindex: one item in the composite has `tabIndex=0`, the rest `-1`, arrows move the zero. Radix `Menu`, `Tabs`, `RadioGroup`, and `Toolbar` implement it. Use them.

### 2. Focus management (2.4.3, 2.4.7)

- Never remove an outline without replacing it: `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`. `:focus-visible` keeps mouse clicks clean while keyboard focus stays obvious.
- Dialogs: focus moves in on open, is trapped while open, returns to the trigger on close. Radix `Dialog` and `AlertDialog` handle trap, restore, and scroll lock.
- DOM order matches visual order. No positive `tabindex`. When new content reveals, move focus to its heading or first field, not the container.

### 3. Semantics and ARIA (4.1.2, 1.3.1)

Native element over ARIA role, always: `<button>` beats `<div role="button">`. When ARIA is needed, get name, role, and state right:

- Every control has an accessible name: visible `<label>`, `aria-label`, or `aria-labelledby`. Icon-only buttons need `aria-label`.
- State is exposed: `aria-expanded`, `aria-checked`, `aria-selected`, `aria-current`.
- Relationships are wired: help text and errors via `aria-describedby`, invalid fields via `aria-invalid`.
- Async updates announce through `aria-live="polite"` (errors: `assertive`). Radix `Toast` ships a correct live region.

First rule of ARIA: no ARIA beats bad ARIA. Do not add roles that fight native semantics.

### 4. Color and contrast (1.4.3, 1.4.11, 1.4.1)

- Body text: 4.5:1 against its background. Large text (24px+, or 18.66px+ bold): 3:1.
- UI components and states (input borders, focus rings, meaningful icons): 3:1.
- Meaning never rides on color alone: pair error color with an icon or text.
- Check both themes. A token that passes light can fail dark. Text over images needs a scrim.

## The form pattern

```tsx
<label htmlFor="email">Email</label>
<input id="email" type="email"
  aria-describedby="email-hint email-error"
  aria-invalid={!!error} />
<p id="email-hint">We never share it.</p>
{error && <p id="email-error" role="alert">{error}</p>}
```

Label by `htmlFor`/`id`, hint and error linked by `aria-describedby`, error announced on appearance via `role="alert"`. For real forms, the shadcn `Form` wrapper generates these associations.

## Reduced motion (2.3.3)

Gate non-essential animation on the OS setting: `useReducedMotion()` in Motion, `@media (prefers-reduced-motion: reduce)` in CSS, zeroing durations there.

## Good vs bad

**Bad:** A dropdown built as styled `<div>`s with an `onClick` toggle. No keyboard path, no `aria-expanded`, focus falls to `<body>` when it closes, and the selected state is a color change only.

**Good:** The same dropdown on Radix `Select`: arrows move options, Escape closes and restores focus, `aria-expanded` and `aria-selected` track state, the selected item also gets a check icon.

## Verification

Unplug the mouse and drive the surface with keyboard only: Tab to every control, operate composites with arrows, open each overlay, press Escape. Expect: everything reachable, focus always visible, every overlay returns focus to its trigger. Then run `npx axe-core` (or the browser axe extension) on the page in both themes. Expect zero critical or serious violations. Any failure: fix the component, do not suppress the rule.

## Completion checklist

- [ ] Keyboard-only pass completed: all actions reachable and operable
- [ ] Focus visible everywhere; overlays trap and restore focus
- [ ] Every control has an accessible name; state exposed via correct `aria-*`
- [ ] Errors and async updates announce via live regions or `role="alert"`
- [ ] Contrast checked in light AND dark: 4.5:1 text, 3:1 UI
- [ ] Meaning never color-only; reduced motion honored
- [ ] Widgets built on primitives, not hand-rolled roles

Any box unchecked: not done. Fix or say so.

## Footguns

- **`outline-none` with no replacement.** The single most common regression. Grep for it; every instance needs a `focus-visible:ring`.
- **ARIA added to fix a symptom.** Slapping `role="button"` on a div still lacks Space/Enter handling and focusability. Use `<button>` or the primitive.
- **Light-theme-only contrast checks.** Muted-foreground tokens routinely pass light and fail dark. Check both or you shipped half an audit.
- **Live regions injected after the fact.** An `aria-live` container must exist in the DOM before its content changes, or nothing announces. Mount it empty, then fill it.
