Accessibility

What every component guarantees about focus, keyboard, labelling and contrast — and what stays your job.

What is guaranteed

These hold for every component in the library, and they hold because they are written into the component itself — not because a wrapper enforces them at runtime.

  • A visible focus ring, everywhere. All 24 components draw focus-visible:ring-2 from --ring. Nothing relies on the browser default, and nothing removes the outline without replacing it.
  • Real semantics, not divs with handlers. Buttons are <button>, links are <a>, and where no element fits, the ARIA role is explicit — listbox, combobox, tablist, dialog, radiogroup, switch.
  • State is announced, not only drawn. Selection, expansion, invalidity, sort direction and busy states all carry the matching aria-* attribute alongside the styling that shows them.
  • Both modes are contrast-checked. Every foreground token is paired with the surface it is meant to sit on, in light and dark, before a palette ships.

Keyboard

Anything you can do with a pointer, you can do without one. The bindings follow the ARIA authoring practices rather than inventing their own.

KeyWhereWhat it does
TabEverywhereMoves between components. Composite widgets are one stop, not one per item.
↑ ↓Select · Tabs · Radio group · CalendarMoves between options inside the widget.
← →Tabs · CalendarPrevious and next tab, previous and next day.
Home · EndSelect · Tabs · CalendarFirst and last option, first and last day of the row.
PageUp · PageDownCalendarPrevious and next month.
Enter · SpaceButtons · Select · Switch · CheckboxActivates, or commits the highlighted option.
EscapeDialog · Select · Calendar · Toast · TooltipCloses, and returns focus to whatever opened it.
Type-aheadSelectTyping letters jumps to the matching option and scrolls it into view.

Labelling

The one thing a component cannot do for you is know what it is called. Three patterns cover almost every case.

usage.tsx
{/* An icon-only control needs a name. There is no visible text
    to fall back on, so the label is not optional. */}
<Button size="icon-sm" variant="ghost" aria-label="Copy command">
  <CopyIcon className="size-3.5" />
</Button>

{/* Decorative marks are hidden instead — a screen reader announcing
    "image" here adds nothing. */}
<CheckIcon aria-hidden="true" className="size-3.5" />

{/* Field errors are associated, not just coloured red. */}
<Input aria-invalid={Boolean(error)} aria-describedby="email-error" />
<p id="email-error">{error}</p>

Icon-only controls are the common miss

A button whose entire content is an SVG has no accessible name unless you give it one. If you lift an icon button out of an example, change the aria-label to match what yours actually does.

Focus

One class string, used by every component, so the ring is identical everywhere and moves with your palette.

convention
// Every interactive element, without exception.
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
  • focus-visible, not focus — the ring appears for keyboard and programmatic focus and stays out of the way of a mouse click.
  • Overlays move focus in on open and return it to the trigger on close. Focus is never left on an element that has been removed from the page.
  • Escape is handled at the overlay, so a nested control cannot swallow it and leave a dialog with no keyboard exit.
  • Focus order is DOM order. Nothing uses a positive tabIndex to reshuffle it.

What stays your job

A component library can guarantee the component. It cannot guarantee the page you put it on.

  • Heading order. Nothing here renders an <h1> on your behalf, and only you know where a section starts.
  • Your own token values. Change --primary to something pale and --primary-foreground stops passing contrast. The pairing is checked for the palettes that ship, not for the one you write.
  • Copy that reads. A dialog announced as "Are you sure?" is accessible and still unusable.
  • What you put around a component. The guarantees stop at its own markup. A Dialog opened from a div with an onClick is unreachable by keyboard, and nothing inside the dialog can fix that.

Each component page carries the source behind its preview. When you need to know whether a label is already set, read the file — it is the only answer that cannot be out of date.