SKILL.md
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-visiblekeeps 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
DialogandAlertDialoghandle 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, oraria-labelledby. Icon-only buttons needaria-label. - State is exposed:
aria-expanded,aria-checked,aria-selected,aria-current. - Relationships are wired: help text and errors via
aria-describedby, invalid fields viaaria-invalid. - Async updates announce through
aria-live="polite"(errors:assertive). RadixToastships 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
<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-livecontainer must exist in the DOM before its content changes, or nothing announces. Mount it empty, then fill it.