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.
Last updated
Theming is a single idea: every visual decision is a CSS variable, and you override it in one file.
Tokens are defined once in @vegastack/design-tokens/theme.css as --<name> variables, bridged into Tailwind
through @theme inline, and consumed by components only as semantic utilities (bg-primary,
text-muted-foreground). Redefine a variable and every component repaints — light and dark, no
component edits.
How .dark works
theme.css ships two token sets for the same variable names: light values on :root, dark values
on .dark. Dark mode is a class toggle, declared at the top of theme.css:
@custom-variant dark (&:where(.dark, .dark *));A component never asks "am I dark?" — it reads var(--background), and the cascade resolves it to the
light value under :root or the dark value under .dark:
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
}Every token is redefined in .dark, the status families included: a
bg-destructive surface is lifted in dark so its -foreground and -text
inks keep clearing the AA floor. That is checked fail-closed in both themes by
the token contrast gate. See Colors.
The @theme inline bridge
Components don't reference --background directly — they use Tailwind utilities like bg-background.
The link between the two is the @theme inline block, which maps each raw token to a Tailwind
--color-* (and --radius-*, --text-*, --font-*, --ease-*) variable:
@theme inline {
--color-background: var(--background);
--color-primary: var(--primary);
--color-muted-foreground: var(--muted-foreground);
/* …radius, type, font, motion bridges… */
}Because the bridge points at var(--primary) (not a baked value), it is transparent to overrides.
Change --primary and --color-primary follows automatically, so bg-primary, every alpha of it a
component writes (hover:bg-primary/80), and the --ring-keyed focus outline all repaint at once.
The bridge also exposes the non-color scales as Tailwind theme keys:
- Radius — one token,
--radius, with--radius-sm/md/lg/xl/2xl/3xl/4xlderived from it (0.6x / 0.8x / 1x / 1.4x / 1.8x / 2.2x / 2.6x). Retune the shape of the whole system by changing that one value. - Type — Tailwind's own
--text-*ramp, unremapped:text-smis 14px everywhere. - Fonts —
--font-sans/--font-mono/--font-serif, bridged from--font-family-*. - Motion —
--ease-standard/--ease-emphasized/--ease-exit/--ease-spring, bridged from--motion-ease-*; durations as--duration-fast|base|slow.
--radius-sm
0.6 × --radius · 6px
--radius-md
0.8 × --radius · 8px
--radius-lg
1 × --radius · 10px
--radius-xl
1.4 × --radius · 14px
The one-file override
This is the headline. To rebrand the entire system, redefine the variables you care about in your app's
global CSS — nothing else changes. Override on :root for both themes, or scope to .dark to
diverge a token in dark mode only:
/* app/globals.css — loaded after @vegastack/design-tokens/theme.css */
:root {
/* brand action color, light + dark */
--primary: oklch(0.53 0.189 295);
--primary-foreground: oklch(0.985 0 0);
/* tighter corners everywhere */
--radius: 0.5rem;
}
.dark {
/* lift the brand fill in dark mode only */
--primary: oklch(0.72 0.155 295);
}Every Button, Badge, focus ring, and bg-primary surface picks this up — because they all resolve
through the same variable. You never touch a component file. Keep overrides in OKLCH and reuse the
semantic names; do not hardcode hex or introduce new raw palettes.
Surfaces & text
Action — neutral ink
Info — links & informational (the one chromatic accent)
Destructive
Success
Warning
Lines & focus
Brand
Media chrome
Photo scrim
Charts — categorical series
Tags — categorical metadata (not status)
Sidebar surface
Focus rings
Focus is one convention, theme-aware via a single token. The visible :focus-visible outline is keyed
off --ring (light oklch(0.205 0 0), dark oklch(0.922 0 0)), drawn as a 2px outline
with 1px offset — there are no separate ring-width or offset tokens. Override --ring and every
focusable element follows.
Press Tab to move focus onto these controls and reveal the --ring outline.
Reduced motion
Motion respects the OS preference globally — it is baked into @vegastack/design-tokens/base.css, not left to
individual components:
@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 !important in the system. Generated VegaStack animated icons enforce
reduced motion intrinsically. For other application-authored JS motion, configure the renderer (for
example Motion's <MotionConfig reducedMotion="user">) to honor the same preference. The specimen
below collapses to its static state under reduced motion:
Duration
--duration-fast
150ms
--duration-base
200ms
--duration-slow
300ms
Easing
--motion-ease-standard
cubic-bezier(0.2, 0, 0, 1)
--motion-ease-emphasized
cubic-bezier(0.3, 0, 0, 1)
--motion-ease-exit
cubic-bezier(0.4, 0, 1, 1)