---
title: "a11y skill: usable by keyboard, screen reader, zoom and reduced motion"
description: "Accessibility as craft for websites and web apps, targeting WCAG 2.2 AA with APCA as the quality bar."
canonical: https://void-design.vercel.app/docs/a11y
lastModified: 2026-09-16
---

# a11y — usable by keyboard, screen reader, zoom and reduced motion

The target is WCAG 2.2 AA as the hard gate and a Lighthouse/axe score of 100, with APCA as the perceptual quality bar. Accessible markup also makes pages readable to AI agents and crawlers (see the `seo` skill).
Paste-ready components: `references/patterns.md`. Visual tokens and focus ring styling: the `craft` skill.

## The 10 rules that matter most

1. **Use the native element.** `<a href>` navigates, `<button type="button">` acts, `<label>` labels. Never put `onClick` on a `div` or `span`.
2. **Every interactive element is reachable and operable by keyboard** in DOM order. No positive `tabindex`. No keyboard traps except inside modal dialogs.
3. **Visible focus everywhere:** `:focus-visible` ring 2px solid `--line-focus`, offset 2px, ≥ 3:1 against the surface. Never remove an outline without a replacement.
4. **One `<main>`, one `<h1>`, headings in order**, with landmarks `<header>`, `<nav aria-label>`, `<footer>`. A skip link to `#main` comes first in `<body>`.
5. **Contrast:** body text ≥ 4.5:1 (APCA Lc 75+), large text ≥ 3:1, UI borders, icons and focus rings ≥ 3:1. Hover and focus states increase contrast, never decrease it.
6. **Hit targets ≥ 24×24 CSS px** (WCAG 2.5.8), **≥ 44×44 on touch.** Grow the hit area with a pseudo-element, not by changing the layout.
7. **Every image has `alt`.** Informative images get a description of their content; decorative ones get `alt=""`. Icon-only buttons have an accessible name (`aria-label` or `sr-only` text).
8. **Forms:** a visible `<label>` per field (a placeholder is not a label), correct `type`/`autocomplete`, errors as text linked with `aria-describedby` + `aria-invalid`, and focus moves to the first invalid field on submit.
9. **Dialogs, menus, comboboxes and tabs use a proven primitive** (native `<dialog>`, Radix, Base UI, React Aria). Don't hand-roll focus traps.
10. **Respect `prefers-reduced-motion`.** Nothing flashes more than 3×/s, and auto-moving content over 5 s has a pause control. Content never depends on an animation finishing.

## Structure

```tsx
// app/layout.tsx (inside <body>)
<a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-surface-raised focus:px-4 focus:py-2 focus:text-fg">
  Skip to content
</a>
<header>…<nav aria-label="Primary">…</nav></header>
<main id="main">{children}</main>  {/* modern browsers move the focus start point to the target; no tabIndex needed */}
<footer>…</footer>
```

- **Exactly one `<main>`.** A layout `<main>` plus a page `<main>` gives two landmarks and a broken skip link. Pages render `<article>`/`<section>` inside it.
- Multiple `<nav>`s each get a distinct `aria-label` ("Primary", "Footer", "Breadcrumb").
- Headings describe their section. Don't skip levels going down (h2 → h4). Don't pick heading levels for font size; style with tokens instead.
- `<html lang="en">` matches the content. Mark phrases in another language with `lang`.
- `<title>` names the page and site ("Pricing | Acme"). Client route changes in Next update it automatically.
- Lists are `<ul>`/`<ol>`, tables are `<table>` with `<th scope>`, times are `<time dateTime>`.
- Links say where they go ("Read the pricing guide"), not "click here". Links that open a new tab say so (visible icon + `sr-only` "(opens in new tab)").

## Keyboard and focus

```css
/* app/globals.css: base layer (Tailwind v4) */
@layer base {
  :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
    outline: 2px solid var(--line-focus);
    outline-offset: 2px;
  }
  :where(input, textarea, select):focus-visible { outline-offset: 0; }
  html { scroll-padding-top: calc(var(--header-h, 64px) + 16px); } /* sticky header never hides focused elements (2.4.11) */
}
```

- Tailwind v4: `outline-none` now literally sets `outline-style: none`. Use `outline-hidden` (which keeps a transparent outline for forced-colors mode) and always pair it with a `focus-visible:` ring. The utility form is `focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus`.
- On a colored or brand surface, use the double ring: `box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--line-focus)`.
- Focus rings appear instantly (no transition), because focus feedback must not lag.
- Composite widgets (tabs, menus, radio groups, toolbars, listboxes, grids) use **one tab stop + arrow keys** (roving tabindex). Page navigation uses plain links, each a tab stop.
- `Escape` closes the topmost overlay and returns focus to its trigger.
- Global shortcuts ignore keypresses inside inputs and editors. Single-key shortcuts must be remappable or disableable (2.1.4).
- Hide inert content with `inert` (off-canvas menus, content behind non-dialog overlays), not just visually.
- `display:none`/`hidden` removes from both the a11y tree and tab order. `aria-hidden="true"` removes only from the a11y tree, so never put it on focusable content.

## Hit targets and pointer

```css
.hit { position: relative; }
.hit::before { content: ""; position: absolute; inset: -8px; }            /* 24px icon → 40px target */
@media (pointer: coarse) { .hit::before { inset: -12px; } }               /* ≥ 44px on touch */
```

- Checkbox/radio + label are one hit area (the whole `<label>` row).
- Adjacent small targets have ≥ 8 px gaps.
- Dragging (sliders, reorder, swipe) always has a click/tap alternative (2.5.7).
- Hover-only UI is forbidden. Anything revealed on hover also appears on focus and is reachable by touch.
- `touch-action: manipulation` on controls removes the double-tap zoom delay.

## Color and contrast (tie to tokens)

| Content | WCAG 2.2 (hard gate) | APCA Lc (quality target) | Token pairs to check |
|---|---|---|---|
| Body text ≤ 18px | ≥ 4.5:1 | ≥ 75, **90 preferred** | `--fg` on `--bg`, `--surface`, `--surface-raised` |
| Secondary text, UI labels | ≥ 4.5:1 | ≥ 60 | `--fg-muted` on every surface |
| Large text (≥ 24px, or ≥ 18.66px bold) | ≥ 3:1 | ≥ 45 | display sizes on `--bg` |
| Meta, captions, placeholders | ≥ 4.5:1 | ≥ 60 | `--fg-subtle` (AA on `--bg`, `--bg-subtle`, `--surface`, `--surface-raised`) |
| Disabled controls, `aria-hidden` decoration | 3:1 (disabled is exempt from 1.4.3) | ≥ 30 | `--fg-faint` — never text someone must read |
| Input borders, focus ring, meaningful icons, chart marks | ≥ 3:1 (1.4.11) | ≥ 45–60 for icons | `--line-strong`, `--line-focus` vs adjacent surface |
| Text on brand | ≥ 4.5:1 | ≥ 60 | `--fg-on-brand` on `--brand` and `--brand-hover`; `--brand-text` on `--bg` |
| Status text | ≥ 4.5:1 | ≥ 60 | `--danger`/`--success`/`--warning` text on `--bg` and their `-subtle` fills |

- Check both **light and dark** themes. WCAG 2 overstates light-on-dark contrast, so APCA is stricter there.
- Never convey meaning by color alone. Errors get an icon + text, status gets a label, links in body text get an underline.
- Don't put `--fg-muted` gray text on colored fills. Use the fill's own text token.
- Support `forced-colors: active` (Windows High Contrast): don't rely on `background-image` or `box-shadow` alone for borders or focus. Outlines survive; shadows don't.

## Forms

- `<label for>` or a wrapping `<label>`, visible and above the field. Required fields: `required` + visible "(required)" text or a legend note.
- `type="email|tel|url|number"`, `inputMode`, `autoComplete="email|name|current-password|one-time-code"` (1.3.5). `spellCheck={false}` for codes and emails.
- Validate on blur and on submit, not on every keystroke. Once an error shows, re-validate on input.
- Error: text below the field with an icon; `aria-invalid="true"`; `aria-describedby` → error id; focus the first invalid field on submit. For long forms, add an error summary with links.
- Don't disable the submit button before the user tries. Let submit reveal what's missing. While submitting, keep the label ("Saving…") and set `aria-busy`/`disabled` on the button.
- Group radios and checkboxes in `<fieldset>` + `<legend>`.
- Never block paste. Allow password managers (3.3.8): no CAPTCHA-only logins, no copy-blocking on OTP fields.
- Field height ≥ 44 px on touch. Font size ≥ 16 px on inputs so iOS doesn't zoom.

## Motion

- Every animation is wrapped by `@media (prefers-reduced-motion: no-preference)`, or neutralized under `reduce`. See `speed/references/rendering-smoothness.md` §2 and the `motion` skill.
- Under reduced motion, replace movement with ≤ 150 ms opacity changes or nothing. Keep essential feedback (progress, state change).
- No parallax, zoom-on-scroll, large translations or auto-advancing carousels under `reduce`.
- Auto-playing video, marquees and carousels > 5 s get a visible pause button (2.2.2).
- Nothing flashes more than 3 times per second (2.3.1).
- Decorative animated text (scramble, typewriter) keeps the real text in `sr-only` and marks the animated copy `aria-hidden`.

## Overlays and widgets: choose, don't hand-roll

| Need | First choice | Weight (gzip) | Notes |
|---|---|---|---|
| Disclosure / accordion | `<details>`/`<summary>` (use `name` for exclusive groups) | 0 KB | Style `summary`; don't hide key SEO content in closed panels |
| Modal dialog | native `<dialog>` + `showModal()` | 0 KB (tiny client leaf) | Background becomes inert, Esc closes, focus returns to the opener. `closedby="any"` is Chromium/Firefox only, so add a backdrop click handler |
| Non-modal popover / simple menu of links | `popover` attribute + `popovertarget` | 0 KB | Light-dismiss and Esc built in; Baseline 2025 |
| Button opens dialog/popover without JS | `command`/`commandfor` invokers | 0 KB | Baseline newly available (Safari 26.2+); keep a JS fallback for older browsers |
| Dropdown menu, context menu, select, combobox, date picker | Radix (`radix-ui`), Base UI (`@base-ui/react`), or React Aria Components | Radix Dialog ~12.6 KB, Tabs ~8.4 KB, Popover ~22.6 KB (includes Floating UI) | Import per component. The full packages are large if not tree-shaken (Base UI ~147 KB, React Aria Components ~274 KB). Measure with `void perf` |
| Toasts | `sonner` | ~9.4 KB | Announces via a live region; toasts only for mutations |
| Tabs | Radix Tabs or the pattern in `patterns.md` | 0–8 KB | Arrow keys, `aria-selected`, `aria-controls` |

Use one primitive library per app. Vendored registry components (shadcn blocks, Magic UI, Aceternity, React Bits) often ship with no ARIA and no reduced-motion handling, so audit them before use.

## Images, media, icons

- `alt` describes purpose and content in ≤ 125 chars. Don't start with "image of". Logos: the company name. Linked images: the link destination.
- Charts: a text summary or data table next to the chart. Complex SVG: `role="img"` + `<title>`/`aria-label`.
- Decorative SVG icons: `aria-hidden="true"` (lucide does this by default). Icon-only buttons: `<button aria-label="Close">`. `title` alone isn't enough.
- Video: captions (`<track kind="captions">`), no autoplay with sound, controls available. Audio-only content gets a transcript.
- Canvas that conveys status: `role="img"` + `aria-label`. Decorative canvas: `aria-hidden="true"`.

## Live regions and status

- Toast and async results: `role="status"` (polite) for success and info; `role="alert"` only for errors that need immediate attention.
- The live region must **exist in the DOM before** its text changes. Mount it empty at startup and update its text later.
- Loading: `aria-busy="true"` on the region being updated. Spinners get `sr-only` "Loading…" text.
- Don't announce on every keystroke. Debounce search result counts ("12 results").
- Status is never color-only: pair the dot with a text label (`role="status"` where it updates).

## Zoom, reflow, text spacing

- Never set `maximum-scale=1` or `user-scalable=no`.
- **Reflow at 320 CSS px width** (≈ 400% zoom) with no horizontal scroll except for tables, code and maps (1.4.10). Test at 320px and at 200% browser zoom.
- Sizes in `rem` for type. Display type uses `clamp()` so the h1 fits at 320px.
- Tolerate user text spacing (1.4.12): no fixed heights on text containers. Use `min-height`, and `line-clamp` only with a way to read the rest.
- `overflow-x: clip` (not `hidden`) on `html`/`body`, so sticky elements and focus scrolling keep working.
- `100dvh`/`svh` instead of `100vh` for full-height layouts on mobile.

## Verify

1. `void lint` (static): `lint/div-button`, `lint/missing-alt`, `lint/outline-none-no-replacement`, `lint/no-reduced-motion`, `lint/multiple-h1`, `lint/missing-lang`.
2. `void a11y --start "next start -p 3000" --port 3000 --format md` on a production build: axe-core (WCAG 2.2 AA + best practices) on mobile and desktop, plus void's own checks `a11y/focus-not-visible` (keyboard focus walk), `a11y/target-size` (mobile), `a11y/reflow-overflow` (320px). Common axe ids: `a11y/color-contrast`, `a11y/button-name`, `a11y/link-name`, `a11y/label`, `a11y/image-alt`, `a11y/heading-order`, `a11y/landmark-one-main`, `a11y/bypass`, `a11y/meta-viewport`, `a11y/aria-hidden-focus`, `a11y/nested-interactive`, `a11y/link-in-text-block`. Unmapped axe rules arrive as `a11y/axe-other` with the axe id in the message.
3. **Stop when:** zero axe violations, the focus walk passes, a11y score 100. Then do a 2-minute manual pass: Tab through the page, open and close every overlay with the keyboard, and use VoiceOver (macOS: Cmd+F5) or NVDA on the main flow.
4. Automated tools catch only part of real issues. The manual pass is not optional for dialogs, menus and forms.
