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-2from--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.
| Key | Where | What it does |
|---|---|---|
| Tab | Everywhere | Moves between components. Composite widgets are one stop, not one per item. |
| ↑ ↓ | Select · Tabs · Radio group · Calendar | Moves between options inside the widget. |
| ← → | Tabs · Calendar | Previous and next tab, previous and next day. |
| Home · End | Select · Tabs · Calendar | First and last option, first and last day of the row. |
| PageUp · PageDown | Calendar | Previous and next month. |
| Enter · Space | Buttons · Select · Switch · Checkbox | Activates, or commits the highlighted option. |
| Escape | Dialog · Select · Calendar · Toast · Tooltip | Closes, and returns focus to whatever opened it. |
| Type-ahead | Select | Typing 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.
{/* 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
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.
// Every interactive element, without exception.
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"focus-visible, notfocus— 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.
Escapeis 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
tabIndexto 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
--primaryto something pale and--primary-foregroundstops 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
Dialogopened from adivwith anonClickis 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.