Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
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 statedefault / 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 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.
  • OperableEnter / 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