a11y
Keyboard and Assistive Access
Build the keyboard path first and let the pointer be the enhancement. Every interactive thing must be reachable by Tab, operable by Enter, Space or arrows, and announced with a name, a role and a state — and the cheapest way to get all of that is to stop reimplementing it. A <button> already carries the role, the focus behaviour, the Enter/Space handling, the disabled semantics and the forced-colors treatment that a div with a click handler will get wrong for the next three years. Assume nothing about the user's input device and change nothing that the platform already does correctly. Pointer physics belong to touch-input — hit-target size, hover gating and tap latency are its nouns and never appear here; a11y owns the keyboard and the accessibility tree. Contrast ratios have exactly one owner and it is color; do not restate them.
Read the project before adding anything. If it already renders Radix, Base UI, React Aria or Headless UI primitives, the focus trap, the roving tabindex and the ARIA wiring exist — configure them and delete your parallel implementation. If there is a FocusScope or useFocusTrap in the codebase, that is the one. If the project is plain semantic HTML, the fix is almost always swapping an element, not adding attributes. Never introduce a second a11y layer next to a working one; two focus traps fight and the outer one wins.
| Topic | Reference |
|---|---|
| Wiring one specific widget | Open references/aria-recipes.md when you need the exact key bindings and ARIA contract for a dialog, menu, combobox, tabs, disclosure, listbox or grid. |
| Sweeping a screen or a PR | Open references/audit.md when you are checking existing work and need the pass order, what each pass catches, and how to rank what you find. |
Core Principles
-
Reach for the native element before the ARIA attribute. ARIA adds semantics and nothing else — no focus, no key handling, no state.
role="button"on adivstill needstabindex, an Enter handler, a Space handler and aaria-disabledstory;<button type="button">needs none. Exception: composite widgets with no native equivalent — combobox, tree, grid, toolbar — which is exactly whatreferences/aria-recipes.mdexists for. -
Tab order is DOM order. Never write a positive
tabindex. A positive value creates a second, invisible ordering that silently drifts the first time anyone reorders the markup. Onlytabindex="0"(add to the order) andtabindex="-1"(focusable by script only) are legitimate. Exception: a roving-tabindex composite, where every child but the active one is deliberately-1. -
Anything invisible must be unfocusable.
opacity: 0,transform: translateX(-100%)andheight: 0all leave the subtree in the tab order — closed drawers and off-screen carousels are where keyboard users fall into a void. Useinerton the container, orvisibility: hidden. Exception: skip links and live regions must stay in the accessibility tree while staying off-screen — clip them withposition: absolute; clip-path: inset(50%), nevervisibility: hidden.