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 — no rings anywhere
Foundation rule (FOC-13): nothing draws a focus ring, Tabs included. The system ships one focus cue
for keyboard users, owned by base.css and never restated by a component:
- Everything focusable — buttons, chips, rows, menu items, toggles, cards, links, checkboxes,
switches — shows a background tint on
:focus-visible:--state-focus, a soft info-hue wash (18% light / 24% dark), laid over the control's own fill as a background image and replacing the hover layer, so it reads on a primary button as well as a ghost one and never looks like "selected". It measures 1.28–1.54:1 against the ground — a tint cannot reach 3:1, and the system draws no ring by decision (FOC-13, FOC-15). It appears only on keyboard focus, never on a mouse click. - Text entry —
Input,Textarea, the OTP slots, questionnaire answers, and the bordered field groups that frame a text field (InputGroup,NumberField,ChipInput,ComboboxChips, the panel search row, the Command input and the popup search rows) — takes no background change at all. Its focus cue is a subtle darker border (border-ring/50) that eases in over 150ms, on plain:focusso a click and a Tab read alike. A field group darkens its own border; the control inside it has no border and no fill. - Borders never move (FOC-14) — except on text entry (MK 2026-09-27). No other control changes
its border colour on focus or while active —
focus,focus-visible,focus-within,data-focused,data-popup-openoraria-expanded: buttons, rows, tabs, menu items, chips, checkboxes, radios, switches and select triggers keep their restingborder-border/border-inputand show the tint. An invalid field keeps its destructive border in every state; focus never replaces it. Nothing moves, and nothing glows. - The document editor (
TextEdit,data-focus-cue="caret") is caret-only: no border change and no fill. Itsboxedvariant — the comment composer — is text entry: the box's border darkens subtly while the editor holds focus, and nothing fills. - Tabs — the triggers and the panel — take the same background tint as everything else; clicking a tab shows nothing.
- Forced colours drop the tint and replace the border colour, so a
forced-colors: activeblock restoresoutline: 2px solid Highlighton every focused element; on a field group the group carries the outline and the control inside stands down (FOC-7). - Overlays — Dialog and Sheet open onto their first field, or the popup itself; never the close ×, so opening one never lights the × up.
Content links remain underlined at rest (list and table row links are the exception: the row tint is enough) and take the same background tint on focus.
Press Tab to move focus onto these controls and reveal the --ring outline.
Do not write a focus class at all. design-lint's no-focus-ring-glow rule
rejects any focus:/focus-visible:ring-*, focus-visible:outline-*,
aria-invalid:ring-*, ring-3 or ring-ring/NN in registry source, so a
ring cannot creep back on an upstream pull, and its no-focus-border rule
rejects any focus-variant border colour (focus:border-*,
focus-within:border-*, data-popup-open:border-* and the like).
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.
Empty states
The ten-tier empty-state system — when a surface has nothing to show, found nothing, or could not load, which tier fits, and the shared icon language.
Theming & dark mode
The override model — how .dark works, the OKLCH :root/.dark token sets, the @theme inline bridge, and the one-file custom theme.