Skip to content
Component installs need the registry setup
VegaStack Design

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/4xl derived 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-sm is 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

--background
--foreground
--card
--card-foreground
--popover
--popover-foreground
--secondary
--secondary-foreground
--muted
--muted-foreground
--accent
--accent-foreground

Action — neutral ink

--primary
--primary-foreground

Info — links & informational (the one chromatic accent)

--info
--info-foreground
--info-text

Destructive

--destructive
--destructive-foreground
--destructive-text

Success

--success
--success-foreground
--success-text

Warning

--warning
--warning-foreground
--warning-text

Lines & focus

--border
--input
--ring

Brand

--brand
--brand-text

Media chrome

--media-scrim
--media-scrim-strong
--media-foreground

Photo scrim

--scrim
--scrim-foreground

Charts — categorical series

--chart-1
--chart-2
--chart-3
--chart-4
--chart-5
--chart-6
--chart-7
--chart-8
--chart-single

Tags — categorical metadata (not status)

--tag-blue
--tag-blue-subtle
--tag-blue-text
--tag-cyan
--tag-cyan-subtle
--tag-cyan-text
--tag-green
--tag-green-subtle
--tag-green-text
--tag-lime
--tag-lime-subtle
--tag-lime-text
--tag-yellow
--tag-yellow-subtle
--tag-yellow-text
--tag-orange
--tag-orange-subtle
--tag-orange-text
--tag-red
--tag-red-subtle
--tag-red-text
--tag-pink
--tag-pink-subtle
--tag-pink-text
--tag-magenta
--tag-magenta-subtle
--tag-magenta-text
--tag-purple
--tag-purple-subtle
--tag-purple-text

Sidebar surface

--sidebar
--sidebar-foreground
--sidebar-primary
--sidebar-primary-foreground
--sidebar-accent
--sidebar-accent-foreground
--sidebar-border
--sidebar-ring

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)

Do
Override semantic tokens in one global file: redefine --primary in OKLCH and let components repaint.
Don't
Edit component files or hardcode hex/raw palettes (bg-[#5b21b6], bg-violet-700) to rebrand.

On this page