Skip to content
Join UI

Accessibility

The WCAG 2.2 AA baseline every component meets.

Every component in this registry is written to WCAG 2.2 AA. This page documents the baseline so you know what you are inheriting — and what you still have to do yourself.

What every component guarantees

Focus

Focus is never suppressed. The global rule shows it for keyboard interaction and hides it for mouse clicks, which is the behaviour :focus-visible was designed for:

app/globals.css
:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

:focus:not(:focus-visible) {
  outline: none;
}

--ring is near-ink in both themes and clears 3:1 against every surface in the system. Because the chrome carries no decorative colour at all, a focus ring never has to compete with one for attention — it is reliably the highest contrast thing on the page.

There is deliberately no border-radius in that rule. An outline already follows whatever radius the element has, so the ring wraps a rounded button and a square code block correctly without either being special-cased. The offset is what keeps it legible against the element's own border.

Components that portal content — command palettes, dialogs, the mobile drawer — use Radix Dialog, which traps focus while open, marks the background inert, and restores focus to the trigger on close. Where an exit animation is involved it is driven with forceMount inside AnimatePresence, so focus returns only after the transition finishes rather than mid-flight.

Colour and contrast

No component conveys meaning through colour alone. This used to be enforced by the palette — the chrome was greyscale, so there was no red to fall back on. That is no longer true: --destructive, --success and --warning are now aliases onto the same red, green and amber the components use, and a callout in these guides is a tinted panel.

Which makes it a requirement met deliberately rather than one the palette enforced for free. WCAG 1.4.1 is satisfied by the glyph and the text label, never by the fill, and a component that skips either of them fails however right it looks.

So state is spelled out. The patterns every component follows:

  • A status indicator renders a text label beside the mark, never a bare dot.
  • A delta shows a direction glyph and a signed figure, not a colour swing.
  • A field error is a message in text, not just a heavier boundary.
  • A selected tab or item carries aria-selected or aria-current, not only a moving indicator.

Body copy clears 4.5:1 in both themes — 5.9:1 in light and 8.2:1 in dark — and the focus ring and emphasis rules clear 3:1. The tightest pair in the system is --caution on --caution-soft at 4.9:1; if you deepen one of those tints, deepen its ink in the same commit. The full contrast table — including which boundaries are deliberately not trying to meet 3:1 — is in theming.

Motion

Two layers of protection. A global CSS media query collapses transition and animation durations:

css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    transition-duration: 0.001ms !important;
  }
}

CSS cannot reach JavaScript-driven animation, so components that animate in JS check the preference themselves:

tsx
const reduceMotion = useReducedMotion()

// Skip the work entirely rather than animating fast.
const active = !reduceMotion && !disabled

The rule that follows from this: reduced motion skips the work rather than speeding it up. A component that tracks the pointer does not attach the listener at all. A component that animates a value renders the final value immediately. A component with a decorative background stops at its resting frame — and where that background is its own stylesheet, it carries a prefers-reduced-motion query itself, so it stays static even in a project without the global rule.

Nothing flashes more than three times per second, so WCAG 2.3.1 is not engaged by any component here.

Keyboard support

Every component page has a Keyboard interactions table generated from its metadata. The patterns those tables draw from:

PatternBehaviour
Tab listRoving tabindex; arrows move and select, Home/End jump, disabled tabs skipped
Command paletteK toggles, arrows navigate, runs, Esc closes
Disclosure / Space on the header toggles aria-expanded
Toolbar or dockPlain tab order; labels appear on focus, not only on hover
Pointer-tracking effectFollows focusin as well as the pointer

A tab list uses roving tabindex deliberately: Tab should move past the whole group in one press, not through every tab.

Announcements

Status changes reach assistive technology without stealing focus:

tsx
// Copy confirmation — polite, so it waits for a pause.
<span aria-live="polite" className="sr-only">
  {copied ? "Copied to clipboard" : ""}
</span>

A component that reports a changing state wraps itself in role="status", and exposes a prop to turn that off — in a dense table, dozens of live regions produce a stream of interruptions rather than information. Errors are announced assertively; hints and confirmations are announced politely.

Touch targets

Interactive controls are at least 24 by 24 CSS pixels, which is the WCAG 2.2 Target Size (Minimum) threshold. Anything that magnifies under the cursor drops that behaviour entirely on coarse pointers and renders a fixed 40px target instead, because a control that grows as you approach it is meaningless on a touchscreen.

What is still your job

Inheriting accessible components is not the same as shipping an accessible page.

  1. Heading order. Component headings are h3. Give them an h2 section to sit under, and only one h1 per page.
  2. Accessible names. Icon-only triggers need an aria-label that says what they do. Components that render icon-only items require a label on each one for exactly this reason.
  3. Language and landmarks. Set lang on html, provide a skip link, and make sure each page has one main.
  4. Real content. An accessible card with an unlabelled image or a link reading "click here" is still inaccessible.

Testing

Automated tooling catches perhaps a third of what matters. Do both:

bash
pnpm dlx @axe-core/cli http://localhost:3000/components

Then, manually:

  • Tab through the page and confirm focus is always visible and the order matches the layout.
  • Turn on Reduce Motion at the OS level and reload — nothing should lose function.
  • Zoom to 400% and check that nothing is clipped or requires horizontal scrolling.
  • Run through it with VoiceOver or NVDA once. It takes ten minutes and finds what nothing else does.