Colors
Primitive ramps, semantic roles, and how to re-theme.
Three layers
Components only ever reference layer 2. That's what makes re-theming a token edit instead of a refactor.
Primitive ramps
Raw values: --brand-500, --neutral-800. Never referenced by a component.
Semantic roles
What a colour means: --primary, --muted, --destructive. Components use only these.
Theme mapping
@theme inline exposes roles as utilities: bg-primary, text-muted-foreground.
Semantic roles
Each role pairs a surface with a foreground guaranteed to meet contrast on it. Toggle the theme in the header — every pair holds.
--background
--foreground
--card
--popover
--primary
Brand action
--secondary
--muted
Recessed surface
--accent
Hover surface
--destructive
--success
--warning
--info
--border
--input
Control borders
--ring
Focus indicator
Pairings
Never mix a foreground from one role onto another role's surface.
Primary
bg-primary text-primary-foreground
Secondary
bg-secondary text-secondary-foreground
Muted
bg-muted text-muted-foreground
Destructive
bg-destructive text-destructive-foreground
Success
bg-success text-success-foreground
Warning
bg-warning text-warning-foreground
Brand ramp
Kept addressable for marketing surfaces and charts, where a specific step is the point.
50
100
200
300
400
500
600
700
800
900
950
Re-theming
Override layer 2, nothing else
--primary, --primary-foreground, --ring, and the dark-mode block. Every component picks it up. You should never need to edit a component file to change a colour; if you do, that's a bug in the system, not in your theme.Accessibility
- Foreground/background pairs meet WCAG AA (4.5:1) for body text in both themes.
- Borders and focus rings meet the 3:1 non-text contrast minimum.
- Status is never carried by colour alone — pair it with an icon or text.
- The brand green is used as a surface with dark text, never as light text on dark: #CBFE00 on #1D1D1D passes, but the reverse fails at small sizes.