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.
| Token | Role |
|---|---|
--background | the page |
--card | card, popover, sheet, dialog (--popover carries the same value) |
--muted | a well, a track, a skeleton, the rest fill of a filled control |
--accent | an accent well (kept as its own role; hover is the state layer) |
--sidebar | the 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):
| State | Token (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 row | bg-state-selected, or layer-selected over a filled part |
| Highlighted (menu or list cursor) | --state-hover | data-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
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
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 forbg-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.
| Token | Light | Dark |
|---|---|---|
--chart-1 | oklch(0.546 0.245 262.88) | oklch(0.623 0.214 259.81) |
--chart-2 | oklch(0.581 0.118 184.7) | oklch(0.696 0.17 162.48) |
--chart-3 | oklch(0.398 0.07 227.39) | oklch(0.769 0.188 70.08) |
--chart-4 | oklch(0.623 0.222 41.12) | oklch(0.75 0.183 55.93) |
--chart-5 | oklch(0.632 0.246 16.44) | oklch(0.645 0.246 16.44) |
--chart-6 | oklch(0.505 0.213 27.52) | oklch(0.69 0.21 25) |
--chart-7 | oklch(0.598 0.127 104.2) | oklch(0.72 0.15 103.9) |
--chart-8 | oklch(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.
Sidebar surface
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 wholerounded-*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.
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.