Skip to content
Component installs need the registry setup
VegaStack Design

Colors

The semantic OKLCH color tokens — light and dark, with a one-file override.

Last updated

Every color is a semantic token in OKLCH, defined in @vegastack/design-tokens/theme.css as a --<name> CSS variable on :root (light) and .dark. Components only ever use semantic utilities (bg-primary, text-muted-foreground), never raw palettes or hex.

The neutral surfaces

The neutral palette is shadcn's own neutral base — pure achromatic, no warm cast. There is no surface ladder and no alpha ladder: five surface roles, and each component writes its own hover and pressed chrome the way upstream writes it.

TokenRole
--backgroundthe page
--cardcard, popover, sheet, dialog (--popover carries the same value)
--muteda well, a track, a skeleton, the rest fill of a filled control
--accentan accent well (kept as its own role; hover is the state layer)
--sidebarthe rail, which is its own surface rather than an alias

In light, --card is the page colour — oklch(1 0 0), the same white as --background, so a card is separated by its hairline alone. Dark keeps a one-step lift: 0.145 → 0.205. A floating surface is --card plus a Tailwind shadow step — see Elevation.

--muted, --accent and --secondary currently share one value. They are kept as three separate roles on purpose: a consumer can retune a hover without moving every well, which an alias would make impossible.

Interaction states

Every hover, press, selected, open and highlighted fill is a translucent foreground layer over whatever is underneath, never an opaque colour, so the step reads the same on the page, a card, a popover, the sidebar and a muted well, in both themes (COL-24, operator 2026-10-31):

StateToken (light / dark)Use
Hover--state-hover (5% / 4% foreground)state-layer — the one utility; it also gives the press
Pressed--state-pressed (10% / 9%)state-layer, on :active
Selected · current · open--state-selected (8% / 7%), weight 500 on the current rowbg-state-selected, or layer-selected over a filled part
Highlighted (menu or list cursor)--state-hoverdata-highlighted:bg-state-hover
Keyboard focus--state-focus (info hue, 18% / 24%)painted by base.css; layer-focus when a filled part shows a child's focus
Inner tile--state-tile (6% / 7%)bg-state-tile — it composes with the row, so it stays one step above it

A rest fill never carries state, and an inner tile never swaps its fill when its row is hovered. Solid fills keep stepping their own colour (hover:bg-primary/80 active:bg-primary/70, bg-destructive/10 hover:bg-destructive/20).

The hairline

--border is a real token, not a derived alpha: oklch(0.922 0 0) in light, and white at 10% in dark, where a translucent hairline reads correctly over both the page and a card. --input is the same value in light and white at 15% in dark, so a field edge stays visible against the darker page. --sidebar-border matches --border.

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

Per-family variants

Each chromatic family (info, destructive, success, warning) is written in shadcn's own destructive shape — a fill, an on-fill ink, and one addition of ours:

  • fill (--info) — the solid surface, and the tint source for bg-info/10 … bg-info/30.
  • foreground (--info-foreground) — the ink on the solid fill: bg-info text-info-foreground.
  • text (--info-text) — the ink on the page and on the family's own tint: bg-info/10 text-info-text.

The -text half is the one addition, and it exists because the obvious thing is wrong: using the fill as ink on its own tint — bg-destructive/10 text-destructive, which is what upstream writes — measures 3.99:1 in light, under the AA floor. -text reads 6.97:1 on the same composite. The token contrast gate checks every family on every surface and every tint alpha, fail-closed.

Every state a family needs is an alpha of the fill: hover:bg-destructive/20, active:bg-destructive/30. Both halves are theme-aware — the dark values are lifted so the same role stays legible on a dark page. The neutral action colour follows the same pattern with hover:bg-primary/80.

info stays rationed to links and informational UI, and a status hue always means status: a favourite star is text-foreground, not text-warning.

Charts — categorical series

The Charts group ships eight categorical tokens (--chart-1 … --chart-8), consumed as bg-chart-1, text-chart-2, fill-chart-3, etc. via the @theme inline bridge (--color-chart-N: var(--chart-N)). They exist to color categorical data series — one hue per series so adjacent slices, bars, or lines stay distinguishable — not as general-purpose accents.

Unlike the chromatic families, charts are theme-aware. The palette is intentionally categorical, not an artificial equiluminant ring: each series is tuned for separation in its theme, so lightness and chroma legitimately vary. Some direct OKLCH values exceed sRGB and are clipped only for the WCAG calculation; the build emits that gamut report instead of publishing duplicate *-p3 tokens. There is no per-token foreground; use surface tokens for text and axes and never rely on hue alone.

TokenLightDark
--chart-1oklch(0.546 0.245 262.88)oklch(0.623 0.214 259.81)
--chart-2oklch(0.581 0.118 184.7)oklch(0.696 0.17 162.48)
--chart-3oklch(0.398 0.07 227.39)oklch(0.769 0.188 70.08)
--chart-4oklch(0.623 0.222 41.12)oklch(0.75 0.183 55.93)
--chart-5oklch(0.632 0.246 16.44)oklch(0.645 0.246 16.44)
--chart-6oklch(0.505 0.213 27.52)oklch(0.69 0.21 25)
--chart-7oklch(0.598 0.127 104.2)oklch(0.72 0.15 103.9)
--chart-8oklch(0.531 0.182 256)oklch(0.72 0.13 256)

Assign chart colors by index in series order (chart-1, chart-2, …), not by meaning. For status semantics (success / warning / destructive) use the chromatic families instead, so the same hue carries the same meaning everywhere.

Tags — the label palette

The Tag group ships ten chromatic hues (blue, cyan, green, lime, yellow, orange, red, pink, magenta, purple), each a trio: --tag-{hue} (the accent — dots, icons), --tag-{hue}-subtle (chip fill) and --tag-{hue}-text (chip text). They mirror the chromatic-family shape but serve a different job: decorative labels (record categories, markets, custom tags), never status. The neutral tag uses muted / muted-foreground.

Both themes are AA-validated fail-closed in the token contrast gate: every -text clears 4.5:1 on its own -subtle fill and on the page surfaces, and every base clears 3:1 (WCAG 1.4.11) against the background. Consume them through TagGroup, which applies the bordered-tint chip formula (subtle fill + matching-hue hairline + hue text).

Hue is a label, not a signal. Status stays with the five semantic families (success / warning / destructive / info / neutral) so the same hue means the same thing everywhere; tags are free to color by category.

The --sidebar-* family is shadcn's own, and it is a real surface set, not a set of aliases — the rail is tinted off the page (light oklch(0.985 0 0), dark oklch(0.205 0 0)) so it reads as a distinct region without a heavy border:

  • --sidebar · --sidebar-foreground — the rail surface and its ink.
  • --sidebar-primary · --sidebar-primary-foreground — the active-item marker.
  • --sidebar-accent · --sidebar-accent-foreground — the hover and current row.
  • --sidebar-border · --sidebar-ring — the rail hairline and its focus ink, both matching the global values.

They are bridged through @theme inline, so bg-sidebar, text-sidebar-foreground and border-sidebar-border compile as usual, and retuning the rail is a matter of overriding this family alone.

Beyond color

Color is one of several token families. The same semantic-token, light/dark, single-override model extends to the rest of the system:

  • Radius — one base radius, --radius (0.625rem), with the whole rounded-* ramp derived from it.
  • Elevation — borders on resting surfaces; a floating surface takes Tailwind's own shadow-sm / shadow-md / shadow-lg.

Override model

Redefine a single variable in your app's global CSS and every component repaints — light and dark:

:root {
  --primary: oklch(0.55 0.2 264); /* your brand */
}

Because components reference --primary through the @theme inline bridge (--color-primary: var(--primary)), the override flows to bg-primary, to every alpha of it a component writes (hover:bg-primary/80), and to the --ring-keyed focus outline everywhere.

Do
Use semantic tokens: bg-primary, text-muted-foreground, border-border.
Don't
Hardcode hex or raw palettes: bg-[#18181b], bg-neutral-900.

The brand accent

--brand is the phosphor-green marker — theme-split: oklch(0.6 0.17 148) in light (3.5:1 on background/card, so meaningful glyphs clear WCAG 1.4.11) and the full oklch(0.86 0.21 148) phosphor in dark (13.3:1). --brand-text is its page-readable twin, for the rare case where the marker carries a word. It is rationed to marker roles only: the live/AI-state dot, a sparkline endpoint, the terminal prompt glyph. Never fills, borders-at-rest, headlines, or buttons.

Brand and success share the green region deliberately (the brand IS a green); they are never distinguished by hue alone — an AI-live marker always pairs shape/motion/label with the color (pulse dot + "LIVE"/"REASONING" mono label), and success states always carry an icon. Under deuteranopia/protanopia simulation the pair keeps a luminance separation (ΔL ≈ 0.10 light / 0.28 dark) on top of those structural cues, and chart-7 was reassigned from green to olive (hue 104) so data series never collide with the marker.

On this page