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.:focus-visiblegets the ring, and:focus { outline: none }never ships alone. Killing the outline to hide it from mouse users kills it for keyboard users on the same element. Ship:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }and pair everyoutline: nonewith a replacement in the same rule. Exception: text inputs should keep a ring on plain:focus— in a dense form the caret alone is too weak an affordance.Focus enters on open and returns on close. On open, move focus to the first interactive element, or to the dialog container itself when the first control is destructive. On close, return it to the trigger — capture that node at open time, and if it has unmounted, fall back to the nearest surviving ancestor. Focus must never land on
document.body, which sends a keyboard user back to the top of the page. Exception: non-modal surfaces — toasts, ambient popovers, autosave notices — must not take focus at all.One trap at a time, and
Escapecloses exactly one layer. A trap inside a trap is unescapable in some screen-reader modes, andEscapethat closes two layers loses work. Which container a flow gets is decided bymodal-or-page; this skill only requires that whatever it is, it traps once and unwinds one step per press. Exception: an alert dialog confirming a destructive action opened from inside a dialog — a deliberate second layer, which must return focus to the first on close.Announce the change, not the screen. A client-side route change is silent: the DOM swaps and a screen-reader user hears nothing at all. On navigation, move focus to the new
<h1>(tabindex="-1", focus it, do not scroll-jack) and mirror async results into a live region. Exception: a result rendered immediately next to the control that caused it needs no announcement — moving focus into it says more than reading it aloud.A live region must exist before its text does, and defaults to
polite. Injecting the container and its content in the same tick announces nothing in most screen readers; the region has to be observed empty first. Render an emptyaria-live="polite"node on mount and write into it later. Reserveassertive/role="alert"for the two things that earn an interruption: a failed submit and lost data. Exception: if your toast library already renders a polite region, use it — nesting two live regions produces a double announcement.Reduced motion means fewer and gentler animations, not zero (Emil Kowalski). Deleting every transition removes the state change along with the movement, and the user loses the cue entirely. Under
prefers-reduced-motion: reduce, keepopacityand colour transitions; drop translation, scale, parallax and auto-playing loops. Exception: motion that is the content — a video, an explanatory animation — gets a play control rather than deletion.Never take zoom away.
user-scalable=noandmaximum-scale=1fail WCAG 1.4.4 (Resize Text), and the layout must survive 200% zoom and forced text spacing without clipping or overlap. The mobile-input-zoom annoyance that tempts people here has a real fix that costs nothing, and it lives intouch-input. Exception: an element implementing its own pinch-zoom (a map, a canvas) may taketouch-action: nonefor itself — the page around it must still zoom.
Smells and Fixes
| Smell | Fix |
|---|---|
<div onClick> with role="button" |
<button type="button">; delete the role, the tabindex and the key handlers |
:focus { outline: none } anywhere |
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px } |
Panel hidden with opacity: 0 or translate |
inert on the container, or visibility: hidden |
aria-label on an element that already has visible text |
Delete it; label the icon aria-hidden="true" instead |
| Live region rendered together with its message | Render the empty region on mount, write the message after |
aria-live="assertive" on a success toast |
polite; assertive is for failed submits and data loss |
Dialog closes and focus is on <body> |
Store the trigger node at open; restore it, or its nearest surviving ancestor |
@media (prefers-reduced-motion: reduce) { * { animation: none !important } } |
Keep opacity and colour; drop transforms only |
tabindex="1" on a "priority" field |
tabindex="0" and reorder the DOM |
Finding Format
One line per finding, in this order: element → input path that breaks → what the user hits → fix. For example: Filter drawer → keyboard → Tab enters the closed drawer and focus disappears → inert on the container while closed. Naming the input path is what makes the finding reproducible; "not accessible" is not a finding.
Checklist
- Every interactive element is a native control, or has a recipe from
references/aria-recipes.mdbehind it - Tab through the whole screen with the mouse unplugged and never lose the focus ring
- No positive
tabindex; hidden subtrees areinertorvisibility: hidden -
:focus-visiblering defined; no unpairedoutline: none - Every overlay moves focus in, traps once, returns it to the trigger, and closes on
Escape - Route changes move focus to the new heading; async results reach a pre-existing live region
-
assertiveused only for failed submits and data loss - Icon-only controls have an accessible name; decorative icons are
aria-hidden - Reduced motion keeps opacity and colour, drops movement
- Page survives 200% zoom and forced text spacing; nothing blocks pinch-zoom