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:
: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-selectedoraria-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:
@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:
const reduceMotion = useReducedMotion()
// Skip the work entirely rather than animating fast.
const active = !reduceMotion && !disabledThe 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:
| Pattern | Behaviour |
|---|---|
| Tab list | Roving tabindex; arrows move and select, Home/End jump, disabled tabs skipped |
| Command palette | ⌘K toggles, arrows navigate, ↵ runs, Esc closes |
| Disclosure | ↵ / Space on the header toggles aria-expanded |
| Toolbar or dock | Plain tab order; labels appear on focus, not only on hover |
| Pointer-tracking effect | Follows 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:
// 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.
- Heading order. Component headings are
h3. Give them anh2section to sit under, and only oneh1per page. - Accessible names. Icon-only triggers need an
aria-labelthat says what they do. Components that render icon-only items require alabelon each one for exactly this reason. - Language and landmarks. Set
langonhtml, provide a skip link, and make sure each page has onemain. - 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:
pnpm dlx @axe-core/cli http://localhost:3000/componentsThen, 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.