05 · foundations
WCAG 2.2 AA

Accessibility. Measured, not assumed.

Six themes, two modes, and one rule: every ink clears WCAG AA on what it sits on. A test measures it on every change, and this page measures it live.

Inks
12 combinations

No single ink reads on all six brands, so each theme sets two: --fg-on-brand for text on a brand fill, and --fg-brand-text for brand-colored text. The contrast test in the registry checks every pair below in all twelve theme and mode combinations. Switch the theme to see the numbers move.

sampleinkonratiofloor
Aa 12px--fg-on-brand--fg-brand-4.5: text on a brand fill
Aa 12px--fg-brand-text--bg-canvas-4.5: brand text on the page
Aa 12px--fg-brand-text--bg-card-4.5: brand text on a card
Aa 12px--fg-brand-text--bg-surface-brand over --bg-canvas-5: brand text on the tint
Aa 12px--fg-primary--bg-canvas-4.5: body text
Aa 12px--fg-secondary--bg-card-4.5: secondary text on a card
Aa 12px--fg-muted--bg-canvas-4.5: metadata on the page
Aa 12px--fg-muted--bg-card-4.5: metadata on a card
Aa 12px--fg-muted--bg-overlay-4.5: metadata in a dialog
Aa 12px--status-success-fg--status-success-soft over --bg-canvas-4.5: a soft success badge
Aa 12px--status-error-fg--bg-card-4.5: a field error on a card
Focus
2 ways

Links get a 2px brand outline. Buttons and menu items draw their own focus, because the reset removes outlines from them. A bare button with no style of its own takes the .focus-ring class, a box-shadow ring that survives the reset.

icon-button.tsx
tsx
<button type="button" className="focus-ring" aria-label="Close">
  <XIcon aria-hidden />
</button>
Skip link
first in body

Hidden until the first Tab, then a mono chip with a $ in the top left. Point it at your main landmark.

app/layout.tsx
tsx
<body>
  <a href="#main" className="skip-link">skip to content</a>
  …
  <main id="main" tabIndex={-1}>…</main>
</body>
Reduced motion
CSS and JS

The global reset stops every CSS animation and transition for people who ask for less motion. Motion components animate through JavaScript, which that reset never reaches, so every one of them calls useReducedMotion() and drops its movement, delays and springs. The .type-* classes zero their delays too, except .type-late, whose delay is the wait itself.

Screen readers
4 patterns
  • Glyphs are hidden. ◆, $, // and the window dots carry aria-hidden, so a label is read as its words.
  • Animated text keeps a real copy. TypeIn and RollingNumber hide their animated pieces and put the sentence or the number in an sr-only element. Not aria-label: a span has no role, so the label would be ignored.
  • Forms are wired. Field points the control at its error or hint with aria-describedby and sets aria-invalid, and the error is announced as an alert.
  • Current means current. Route tabs and the sidebar mark the active item with aria-current="page", the outline with aria-current="location", filter pills with aria-pressed.
Theme scripts
1 attribute

ThemeScript and ModeScript set data-theme and data-mode on <html> before React loads, so the page never flashes the wrong mode. React then sees attributes it did not render. Add suppressHydrationWarning to your <html> to tell it that is expected.

app/layout.tsx
tsx
<html lang="en" suppressHydrationWarning>
  <head>
    <ThemeScript />
  </head>
  …
</html>
Active theme: entrepta, dark mode.