Skip to content
Component installs need the registry setup
VegaStack Design

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-visible ring — 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-motion respected — 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 :focus so 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-open or aria-expanded: buttons, rows, tabs, menu items, chips, checkboxes, radios, switches and select triggers keep their resting border-border / border-input and 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. Its boxed variant — 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: active block restores outline: 2px solid Highlight on 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 / Space activate, Esc dismisses overlays, arrow keys move within menus, listboxes, and tabs.
  • Labeled — every control has an accessible name; decorative icons are aria-hidden unless given an aria-label.
  • Roles — Base UI emits the right role and state attributes; we never ship <div onClick> as a button.
Do
Use focus-visible:outline-ring, build on Base UI primitives, label every control, and let the global reduced-motion block do its job.
Don't
Use focus: (rings on mouse click), add !important outside the reduced-motion block, or hand-roll <div onClick> controls without roles or keyboard support.

On this page