# React Accessibility

You are building a React 19 app (often Next.js 16 with Tailwind CSS v4 and shadcn-style components on Base UI, Radix or React Aria) that must meet WCAG 2.2 level AA. Treat accessibility as correctness: a control that a keyboard or screen reader user cannot operate is a bug, not a polish item.

## Before you write code

- Check which primitives the project uses (`@base-ui/react`, `radix-ui`, `react-aria-components`) and its `components/ui/` wrappers. Build on them before writing any custom widget. Base UI ships its docs in `node_modules/@base-ui/react/docs/`.
- Check the linter for accessibility rules (ESLint's `jsx-a11y`, Biome's `a11y` group) and the test setup for axe.
- Read the root layout once: `<html lang>` and `dir`, a skip link, the landmarks. Fix them there, not per page.

## Semantics before ARIA

- Use the element that already has the behavior: `<button>` for actions, `<a href>` (or the router's `Link`) for navigation, native inputs, `<fieldset>` and `<legend>` for groups. Never a `div` or `span` with `onClick`.
- Add ARIA only for what HTML cannot express, and then the whole pattern: role, states, keyboard. ARIA adds no behavior, and a wrong role is worse than none.
- One `<h1>` per page and headings in order, chosen for structure, not size. Landmarks: `<header>`, a named `<nav aria-label="…">` per navigation, one `<main id="main">`, `<footer>`. The first focusable element is a skip link to `#main`.
- Every page has a unique, descriptive title, set through the framework's metadata.
- Every control has an accessible name: visible text, a `<label>`, or `aria-label` for icon-only controls. When there is visible text, the name contains it (2.5.3).
- Meaningful images get `alt` that says what they convey; decorative ones `alt=""`. Decorative icons get `aria-hidden="true"` (lucide-react adds it unless you pass an `aria-*`, `role` or `title` prop). An icon-only button carries the name itself: `<button aria-label="Close"><XIcon /></button>`.
- Tabular data is a `<table>` with a `<caption>` (or `aria-label`), `<th scope="col">` and `scope="row"`. Sortable headers hold a `<button>`, with `aria-sort` on the `<th>`.

## Forms and errors

- Tie labels to inputs with `useId()`: `<label htmlFor={id}>` and `<input id={id}>`. Never build ids from `Math.random()` or counters: they break hydration.
- A placeholder is not a label. Mark required fields in text, not only with a red asterisk, and with `required`.
- On error: `aria-invalid="true"` on the control, the message's id in its `aria-describedby` (beside any hint), and text that says how to fix it. After a failed submit, focus the first invalid field or an error summary that links to each field.

```tsx
function EmailField({ error }: { error?: string }) {
  const id = useId();
  return (
    <div>
      <label htmlFor={id}>Email</label>
      <input
        aria-describedby={error ? `${id}-error` : undefined}
        aria-invalid={error ? true : undefined}
        autoComplete="email"
        id={id}
        name="email"
        required
        type="email"
      />
      {error ? <p id={`${id}-error`}>{error}</p> : null}
    </div>
  );
}
```

- Results of async work (saved, failed, 12 results) go to a polite live region (`role="status"`) that is already in the DOM.
- Use `autocomplete` tokens on personal data (`name`, `email`, `current-password`, `new-password`, `one-time-code`) (1.3.5). Allow paste and password managers; never require a puzzle or memory test to sign in without an alternative (3.3.8). Don't ask again for what the user entered earlier in the same flow (3.3.7).
- Prefer an enabled submit button that validates and explains over a disabled one that hides the reason.

## Focus and keyboard

- Menus, comboboxes, listboxes, tabs, dialogs, popovers, tooltips, sliders and date pickers come from Base UI, Radix or React Aria primitives, which implement the WAI-ARIA keyboard patterns, focus handling and labelling. Hand-roll one only when none fits, and then implement the whole APG pattern: roving `tabIndex`, arrows, Home and End, Escape, typeahead.
- Dialogs: focus moves in on open (the first field, or the least destructive button in an alert), Tab stays inside, Escape closes, focus returns to the trigger, the page behind is inert, and a visible title names the dialog. Adjust focus through the library (Base UI's `initialFocus` and `finalFocus`, Radix's `onOpenAutoFocus` and `onCloseAutoFocus`), not with timers.
- Use `inert` (a boolean prop in React 19) to remove a closed drawer, an off-screen panel or the background of a custom modal from the tab order and the accessibility tree. Never `aria-hidden` on a container that still holds focusable elements.
- Route changes: the new page must be announced and focus must start at its top. Next.js's route announcer reads `document.title`, so titles must be unique. In routers without one, focus the new page's `<h1 tabIndex={-1}>` after navigation. Never move focus on filter or search-param changes within a page.
- No focus loss on re-render: keep `key`s stable, never define a component inside another (it remounts every render), and don't swap the focused button for a spinner: keep the button and show the pending state inside it. When you remove the focused element (deleting a row), move focus to a neighbor or the list's heading.
- Show focus on everything focusable with `focus-visible:` styles at 3:1 contrast (`focus-visible:outline-2 focus-visible:outline-offset-2`). Remove the default only with Tailwind CSS v4's `outline-hidden`, which keeps an outline in forced-colors mode, and only when you draw your own.
- Sticky headers and cookie banners must not cover the focused element (2.4.11): set `scroll-padding-top` to the header's height.
- Never use a positive `tabIndex`; use `tabIndex={-1}` only on programmatic focus targets.
- Every drag interaction (reorder, resize, a map pan) has a single-pointer or keyboard alternative (2.5.7).

## Visual design

- Text contrast at least 4.5:1, or 3:1 for large text (24px, or 18.66px bold). Control boundaries, focus indicators, meaningful icons and chart lines need 3:1 (1.4.11). Check light and dark themes and every state; placeholder text counts.
- Never convey meaning by color alone: errors carry an icon or text, the selected tab changes weight or gets an underline, links inside a paragraph are underlined.
- Targets are at least 24 by 24 CSS pixels, or spaced so a 24px circle on each doesn't overlap another (2.5.8). Toolbar icon buttons are the usual failure: pad the hit area, not the icon.
- Respect `prefers-reduced-motion` with Tailwind's `motion-safe:` and `motion-reduce:` variants or the animation library's reduced-motion hook. Replace large movement, parallax and auto-scroll with a fade or nothing. Anything that moves for more than 5 seconds gets a pause control (2.2.2).
- Content reflows at 320 CSS pixels wide without horizontal scrolling (tables, code and maps excepted) and survives 200 percent text zoom: size text in `rem`, never fix the height of text containers.
- Support RTL with logical properties: `ms-*`, `me-*`, `ps-*`, `pe-*`, `start-*`, `end-*`, `text-start`, `border-s`. Mirror directional icons with `rtl:-scale-x-100`, set `dir` on `<html>`, and wrap the app in the primitives' direction provider.
- Tooltips and hover cards also open on focus, stay while hovered, and close on Escape (1.4.13). Never put the only copy of essential information in one.

## Live regions and toasts

- A live region must exist before its content changes: render it empty and update its text. `role="status"` for most updates, `role="alert"` only for errors that need attention now.
- Don't announce every keystroke: debounce result counts. Don't announce what focus already reveals.
- Toasts are for brief, non-critical messages. Anything the user must act on goes in the page or a dialog. Use the library's toast (Sonner, Base UI Toast), pause it on hover and focus, and keep it long enough to read.

## Testing

- Component tests: query by role and name with Testing Library (a control you can't find by role is a bug) and run axe on each state: errors shown, menu open, dialog open.
- End-to-end tests: `@axe-core/playwright` with `withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])` on every key page and open overlay. jsdom can't compute contrast, so the browser run is the one that checks it.
- Automated checks catch only part of the problems. Do a keyboard pass on everything you build, and a screen reader spot check (VoiceOver, NVDA) on new widgets and flows.

## Do not

- Do not put `onClick` on a `div`, `span` or an `<a>` without `href`, or `role="button"` on a link.
- Do not set `aria-label` on a generic `div` or `span` (it is ignored there), or `aria-hidden` on anything focusable.
- Do not use `title` as the only name, or `tabIndex={0}` on static text.
- Do not use `outline-none` without a replacement, or `:focus` styles where `:focus-visible` is meant.
- Do not autoplay media with sound, or use `autoFocus` on page load outside a dialog or a single-purpose page.
- Do not add an overlay "accessibility widget" in place of fixing the markup.

## Before you finish

- Run the linter with accessibility rules and the axe tests, and fix what they report.
- Do a keyboard-only pass on what you changed: every control reachable and operable, focus visible and in a logical order, no traps, Escape closes overlays and focus returns.
- Zoom to 200 percent, narrow to 320 pixels, switch to dark mode and to reduced motion, and check again.
- Say what you could not verify, such as a screen reader pass, so a person does it.
