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

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(0.994 0.002 75);
  --foreground: oklch(0.145 0.003 75);
}

.dark {
  --background: oklch(0.175 0.003 75);
  --foreground: oklch(0.922 0.003 75);
}

Not every token is redefined in .dark. The saturated status + --info fills and their hover/active/foreground states) are theme-independent — a bg-destructive button reads the same on a light or dark page. Theme-aware tokens include --primary, surfaces, --muted-foreground, the subtle / text pairs, --border, --ring, --overlay) appear in both blocks. 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, the ladder recipe's alpha washes (hover:bg-primary/(--alpha-hover), active:bg-primary/(--alpha-pressed) — what fillInteractive.primary expands to), and the --ring-keyed focus outline all repaint at once.

The bridge also exposes the non-color scales as Tailwind theme keys:

  • Radius — the named 2/6/8/12px roles: --radius-xs/--radius-sharp, --radius-sm, --radius-md, and --radius-lg (--radius aliases --radius-lg). There is no --radius-xl; full pills use the semantic rounded-full role.
  • Type — the --text-* ramp (display, h1h4, label, label-sm, code, code-sm), each with a paired --text-<name>--line-height.
  • Fonts--font-sans / --font-mono / --font-serif, bridged from --font-family-*.
  • Motion--ease-standard / --ease-emphasized / --ease-exit, bridged from --motion-ease-*; durations as --duration-fast|base|slow.

--radius-xs

0.125rem · 2px

--radius-sm

0.375rem

--radius-md

0.5rem

--radius-lg

0.75rem

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-hover: oklch(0.48 0.189 295);
  --primary-active: oklch(0.44 0.189 295);

  /* rounder 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
--surface-1
--surface-2
--surface-3
--muted-foreground
--muted-foreground-faint
--border

Action — neutral ink

--primary
--primary-foreground
--primary-hover
--primary-active

Info — links & informational (the one chromatic accent)

--info
--info-foreground
--info-hover
--info-active
--info-subtle
--info-text

Destructive

--destructive
--destructive-foreground
--destructive-hover
--destructive-active
--destructive-subtle
--destructive-text

Success

--success
--success-foreground
--success-hover
--success-active
--success-subtle
--success-text

Warning

--warning
--warning-foreground
--warning-hover
--warning-active
--warning-subtle
--warning-text

Lines & utility

--border
--input
--ring
--track
--overlay

Charts — categorical series

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

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.353 0.003 75), dark oklch(0.922 0.003 75)), 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