Skip to content
Kinetixui

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 --ring outline on :focus-visible (checkbox, radio, switch, list rows, menu items, close buttons, nav). --ring clears 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-focus composite 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 an aria-label; the components don't render without one where it matters.
  • Reduced motion — transitions are short and transition-colors only; 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 ring utilities 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 and Marquee stop; Spinner (and the FileUpload spinner) 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.

WidgetRoleKeysKnown gaps
TreeViewtree / treeitem↓ ↑ move · Home End · → expands, then enters the first child · ← collapses, then goes to the parent · Enter / Space selects—
MultiSelectcombobox (a div) opening a listboxEnter / 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—
ColorPicker2D slider plus hue / alpha sliders and a hex fieldSquare: arrows change saturation / value, Shift = ×10 · rails: arrows, Home End · hex field: Enter commits—
Tourdialog, aria-modalFocus moves into the card on open and on every step · Tab is trapped in the card · Esc closes and returns focus to where it wasFocus is contained by script; the rest of the page is not made inert
DataGridgridOne 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()
KanbanBoardgroup of sortable listsdnd-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-visible draws 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 — Figma onWarningContainer #f97907 was 2.7:1 as text / 2.6:1 in the Tag warning variant. Now amber.800 #7f5b21 (6.1 / 5.8:1) — a muted dark-amber. Dark stays bright amber.400.
  • --destructive — Figma error #ec5047 was 3.3:1. Now red.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).