Accessibility
Accessibility is a build constraint here, not a review step. Most interactive
components are a Radix Primitive underneath,
so keyboard interaction, focus management, and ARIA roles come correct by
default — KinetixUI only styles them against the token contract. A handful of
widgets have no Radix equivalent and are hand-built; their keyboard models are
written out under Hand-built widgets below, and every one is covered by an automated keyboard test
(KanbanBoard, which needs real layout, in the browser pass).
What you get#
- Keyboard — every menu, dialog, tab set, combobox, and disclosure is fully
operable without a mouse (arrow keys,
Home/End,Esc, type-ahead, focus trapping in overlays). This is Radix behaviour, preserved as-is. - Focus visibility — most components draw a 2px
--ringoutline on:focus-visible(checkbox, radio, switch, list rows, menu items, close buttons, nav).--ringclears 8.8:1 against the page in dark mode and 11.6:1 in light — well past the 3:1 that WCAG 2.2 SC 1.4.11 asks for. Form controls (Button, Input, Select, Textarea, Fab, NumberInput, InputGroup, FileUpload) instead use the--shadow-focuscomposite token, which now carries a real dark set (tokens/semantic/shadow.dark.json→@kinetixui/tokens/css/extras/dark), so the ring clears 8.8:1 in dark too — not just on this site. - Text contrast — every foreground/surface pair components actually render
is audited by
scripts/check-contrast.mjs(pnpm check:contrast, run in CI). Every pair meets WCAG AA (4.5:1) in both themes — no exceptions. CI fails the build if a new pair regresses. - Non-text contrast — the same script also checks every hover-fill/border/ focus-ring pair against SC 1.4.11 (3:1). Five pairs sit below 3:1 by design (see "The one trade-off" below) and are tracked as explicit exceptions; any other sub-3:1 pair fails CI the same way a text regression does.
- Labels — icon-only controls (
Button size="icon",Fab, close buttons) require anaria-label; the components don't render without one where it matters. - Reduced motion — transitions are short and
transition-colorsonly; nothing animates position or opacity on a loop.
Forced colors and reduced motion#
Both are checked in a real browser on every story, not just documented:
- Forced colors (Windows High Contrast): Tailwind's
ringutilities are box-shadows, which the mode strips. Every focus stop across all stories must keep a visible outline, so focus is never drawn by a shadow alone. - Reduced motion: with
prefers-reduced-motion: reduce, nothing may loop faster than three seconds.Skeleton, the chart loading placeholder,MessageBubble's typing dots andMarqueestop;Spinner(and theFileUploadspinner) slow to one turn per three seconds rather than stopping, because a frozen spinner no longer says "working".
Hand-built widgets#
Where there is no primitive to lean on, the interaction model is ours, so it is
documented here and pinned by tests in packages/ui/src/components-keyboard.test.tsx
(focus, Tab order, arrow keys, Escape, RTL). — means not supported yet.
| Widget | Role | Keys | Known gaps |
|---|---|---|---|
TreeView | tree / treeitem | ↓ ↑ move · Home End · → expands, then enters the first child · ← collapses, then goes to the parent · Enter / Space selects | — |
MultiSelect | combobox (a div) opening a listbox | Enter / Space opens and moves focus to the search field · ↓ ↑ Enter choose · Esc closes and returns focus to the combobox · Backspace in the empty search field removes the last chip | — |
ColorPicker | 2D slider plus hue / alpha sliders and a hex field | Square: arrows change saturation / value, Shift = ×10 · rails: arrows, Home End · hex field: Enter commits | — |
Tour | dialog, aria-modal | Focus moves into the card on open and on every step · Tab is trapped in the card · Esc closes and returns focus to where it was | Focus is contained by script; the rest of the page is not made inert |
DataGrid | grid | One tab stop (roving) · ← → ↑ ↓ move between cells, the header row included (mirrored under RTL) · Home End row ends, Ctrl+Home / Ctrl+End grid corners · PageUp PageDown · sortable header: Enter / Space sorts · editable cell: Enter / F2 edits, Enter commits, Esc cancels, focus returns to the cell · header: Alt+← → reorders the column, Shift+← → resizes it by 10px (both announced in a live region) · with selectable: drag, or Shift+arrows / Shift+click, to extend a range · Ctrl/⌘+click or Ctrl+Space keeps it and starts another (separate) range · on a column header, Ctrl/⌘+click or Ctrl+Space selects the whole column and Shift+click extends it (a plain click still sorts; selected headers set aria-selected) · Shift+Space on a cell selects its row · Ctrl/⌘+A selects all · Ctrl/⌘+C copies every range as tab-separated text, blank line between ranges · Esc clears (aria-multiselectable, aria-selected) | Column reorder and resize by keyboard are chords, not the pointer gestures' equivalents: reorder skips pinned columns. Each selected range is a rectangle (no lasso, and no row-header gutter: rows are selected from the keyboard), dragging past an edge auto-scrolls the grid (vertically and horizontally; horizontal is off under RTL, where you can extend with Shift+click), and copy uses each column's value() |
KanbanBoard | group of sortable lists | dnd-kit's keyboard sensor: Space / Enter picks up, arrows move, Space / Enter drops, Esc cancels | — (drag within and across columns is exercised in real Chromium by the browser pass) |
The known gaps are deliberate omissions, not oversights.
The one trade-off#
On the darkest surface, a subtle hover fill can't reach 3:1 on its own.
--accent (the hover wash used by ghost/outline buttons, menu items, list rows)
sits at ~1.2:1 against --background in dark mode — visible, but below the
non-text-contrast bar if it were the only cue.
So components never make it the only cue — the highlighted / hovered state also
draws a --ring inset outline (8.8:1 dark / 11.6:1 light):
- Ghost / Outline buttons on
:hover. - Menu items (dropdown, context, menubar, select) and interactive list
rows on
:focus/:hover. - Toggle and menubar triggers in their pressed / open state.
- Everything is also keyboard-reachable, where
:focus-visibledraws the same ring.
If you retheme, keep --ring high-contrast against --background and
--foreground in both modes — the focus outline depends on it. Run
pnpm check:contrast after changing the palette — it now fails the build if
a non-text pair regresses below 3:1 without being added to the script's
tracked-exceptions set on purpose, not just this one.
Palette changes for AA#
Two light-mode semantic colours were moved off their Figma values to clear WCAG AA (dark mode was already fine for both):
--warning— FigmaonWarningContainer#f97907was 2.7:1 as text / 2.6:1 in theTagwarning variant. Nowamber.800#7f5b21(6.1 / 5.8:1) — a muted dark-amber. Dark stays brightamber.400.--destructive— Figmaerror#ec5047was 3.3:1. Nowred.500#c60a0a(5.6–6.1:1).
Remaining: check-contrast.mjs doesn't yet assert the four --shadow-focus*
composites (the dark set exists — tokens/semantic/shadow.dark.json →
@kinetixui/tokens/css/extras/dark — it's just not in the script's checks).