Accessibility
The a11y contract every component honors — WCAG 2.2 AA, a single focus-visible outline, axe-clean states, keyboard support, and reduced motion.
Last updated
Accessibility is a build rule, not a review step. Every component in @vegastack/ui is held to
WCAG 2.2 AA—without dropping any existing 2.1 assertion—and is enforced by lint, the design-audit skill, and a day-one
axe gate. Consumers inherit that contract by installing the
component — there is nothing extra to wire up.
The contract
- WCAG 2.2 AA — the target for every component, color pair, and interaction; existing 2.1 checks remain mandatory.
- Visible
:focus-visiblering — one keyboard-focus treatment, system-wide (see below). axe-clean — automated checks run on day one, not retrofitted.- Every applicable UI state —
default / hover / focus / loading / empty / error / success / disabled. prefers-reduced-motionrespected — enforced globally in tokens, no opt-in needed.- Full keyboard support — every interactive control is reachable and operable without a pointer.
- Semantic roles — correct elements and ARIA via Base UI primitives, never
<div>buttons.
Focus ring
The system ships one focus treatment: a :focus-visible outline keyed off the --ring token.
It appears only on keyboard focus (:focus-visible), never on mouse click, so pointer users don't
see a ring on every press. The convention is a 2px outline at 1px offset — there are no separate
ring-width or ring-offset tokens; --ring (light oklch(0.353 0.003 75), dark oklch(0.922 0.003 75))
is the only knob.
Text-entry controls are the deliberate exception: Input, Textarea, Select, and combobox inputs
re-color their border on :focus so caret and pointer focus remain visible without adding an outer
ring inside dense forms. Other controls use the shared :focus-visible outline.
One further case changes the offset only, never the treatment. If a focusable element cannot
paint outside its own border box — because an ancestor is overflow-hidden, or because a mask
utility such as scroll-fade is applied to it — the outline is
pulled inside with focus-visible:-outline-offset-2. Same width, same --ring token. Do not reach
for the border-tint instead: outside text-entry fields it is not a focus indicator at all, because
forced-colors: active replaces border-color with a system value. Terminal's scrollable command
pane is the shipped example.
Content links remain underlined at rest and keep this same neutral focus-visible outline. Navigation and button-like anchors may rely on their explicit control geometry instead of a resting underline, but never remove the outline without an equally visible replacement.
Press Tab to move focus onto these controls and reveal the --ring outline.
// The sanctioned focus treatment — outline keyed off the --ring token.
<button className="outline-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring">
Save
</button>Use focus-visible: for the shared outer outline. Use focus: only for the
sanctioned text-entry border-tint pattern described above; do not invent a
second focus treatment. Inverting the offset on a clipped or masked container
is not a second treatment — it is the same outline, drawn inside.
Axe-clean from day one
A11y isn't asserted by hand — it's a test gate. Components are audited with axe-core using its
WCAG 2.0, 2.1, and 2.2 AA tags on render, and any violation fails the build:
// packages/ui/test/a11y.ts — preserves 2.0/2.1 and adds WCAG 2.2 AA.
// runOnly: wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa
await expectNoA11yViolations(el);color-contrast can't be evaluated by the fast unit tests (they run without compiled Tailwind, so
semantic tokens like text-popover-foreground don't resolve). Real rendered contrast is instead proven
by a dedicated compiled-CSS browser gate (test/contrast.browser.test.tsx) that compiles the token
theme and runs axe's color-contrast rule for real, in both light and dark.
Reduced motion
prefers-reduced-motion: reduce is honored globally — there is no per-component opt-in. The tokens'
base.css collapses every animation and transition for users who request reduced motion:
/* @vegastack/design-tokens/base.css */
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}This is the only sanctioned use of !important in the system — the build fails on any !important
outside this block. See Motion for the duration and easing tokens this
overrides.
Keyboard & semantics
Interactivity is built on Base UI primitives, which supply the correct roles, ARIA wiring, and focus
management out of the box. Components are server-safe by default — 'use client' lives only at the
lowest interactive leaf — and compose via Base UI's render prop rather than wrapping native semantics
in non-semantic markup.
- Reachable — every interactive control is in the tab order; nothing is mouse-only.
- Operable —
Enter/Spaceactivate,Escdismisses overlays, arrow keys move within menus, listboxes, and tabs. - Labeled — every control has an accessible name; decorative icons are
aria-hiddenunless given anaria-label. - Roles — Base UI emits the right
roleand state attributes; we never ship<div onClick>as a button.