Themes
SubstrateUI's token architecture supports multiple brand themes that layer on top of light/dark mode. Same components, same behaviors, different colors.
Why multi-theme matters
The token architecture is designed so consuming apps can ship with their own brand palette without forking components. A theme is a remapping of semantic tokens — the component library never knows which theme is active; it just renders with whatever values the cascade resolves to. This means a single component ships once and works correctly across every theme a consumer chooses to build.
How themes work
Themes and modes are orthogonal axes. The theme attribute selects the palette; the .dark class toggles the mode within that palette. Cascade order:
:root { /* plum light — the default role */ }
.dark { /* plum dark — overrides :root */ }
[data-theme="ocean"] { /* ocean light */ }
[data-theme="ocean"].dark { /* ocean dark */ }The default theme is :root (no attribute required). Alternative themes attach via [data-theme="..."] on the <html> element. Because theme selectors come after the default declarations in the stylesheet, they win on specificity ties — and the compound selector [data-theme="ocean"].dark beats plain .dark for theme-specific dark overrides.
How to enable a theme in your app
Set the data-theme attribute on <html> and toggle dark as you normally would:
<html data-theme="ocean">
<body className={isDark ? "dark" : ""}>
{children}
</body>
</html>These docs include a theme picker at the top of every page — switch between themes, then toggle light/dark and LTR/RTL to see all combinations in action.
How to add your own theme
Three additions to tokens.css give you a new theme:
/* 1. Raw palette (OKLCH, 50–950 ramp) */
:root {
--raw-ocean-50: oklch(0.97 0.02 220);
/* ...down to --raw-ocean-950 */
}
/* 2. Semantic mapping — light */
[data-theme="ocean"] {
--background: var(--raw-ocean-50);
--foreground: var(--raw-ocean-900);
--primary: var(--raw-ocean-600);
/* ...mirror every semantic token from :root */
}
/* 3. Semantic mapping — dark */
[data-theme="ocean"].dark {
--background: var(--raw-ocean-950);
--foreground: var(--raw-ocean-100);
--primary: var(--raw-ocean-500);
/* ...mirror every semantic token from .dark */
}Mirror every semantic token that exists in :rootinside your theme's light block, and every token from .dark inside your theme's dark block. Missing mappings fall through to the default theme and create subtle cross-theme bugs. Then run bun run audit:contrast to verify WCAG AA compliance across every pairing, and test both modes visually.
Contrast is theme-specific
Every theme must independently pass WCAG AA. A palette that works beautifully in light mode may fail its dark counterpart — a green that reads well on white often becomes too bright against a dark background. The contrast audit iterates all themes in all modes and fails the build if any pairing drops below threshold. Adjust OKLCH lightness values in 0.05 increments until every row passes.
Theme DNA
Every theme is defined as much by what it refuses as by what it uses. The NOT-lists below keep the shipped themes from drifting from drifting toward each other — and set the bar for any theme you add.
Plum
Emotional keywords: Warm, tactile, confident, grounded, friendly-but-serious. Feels like: Quality stationery — cream paper, saturated ink, edges you can run a thumb over.
This theme is NOT:
- Cold or clinical — every neutral is warm; never introduce pure or blue-tinted grays
- Glassy or floaty — no blur, no translucency, no soft elevation; depth is borders and hard offset shadows
- Flat minimalism — nothing borderless; components wear their 2px borders proudly
- Neon — plum is ink, not electricity; amber is the only accent that shouts, and it's used sparingly
Lava
Emotional keywords: Volcanic, energetic, elemental, high-contrast. Feels like: Raw heat under a dark crust — cooled to the same hard edge as the rest of the set.
This theme is NOT:
- Soft — lava is hot, not molten; the geometry is the house cut corner and hard stop, same as every other theme
- Alarming — magma is heat, not danger; errors stay cherry red, so never use the primary for destructive actions
- Cyberpunk or neon — the palette is geological (magma, sulfur, basalt), never electric or glitchy
- Gray-shadowed — the hard shadow is tinted deep magma, embers under the crust; that tint is lava's one structural tell
Proof
Emotional keywords: Industrial, exact, printed, registered, legible. Feels like: A proof pulled off the press — process inks on cool stock, trim marks in the margin.
This theme is NOT:
- Corporate SaaS — cyan is a process ink, not a brand gradient; never fade it into anything
- Warm — the stock is deliberately cool (hue 264); cream paper belongs to plum
- Soft — corners are cut rather than rounded and motion stops hard; never add easing that floats
- Alarming — process magenta is brand furniture (marks, charts) and is never a status colour
Substrate
Emotional keywords: Measured, cold, instrumental, precise, quiet. Feels like: The instrument rather than the print — a dial settling on a reading.
This theme is NOT:
- Decorative — jade earns its place by marking state, not by looking pleasant
- Warm — graphite neutrals carry no cream; amber signals rather than decorates
- Bouncy — motion is a dial settling, never an overshoot
- Lava in green — the palette is cool and exact, never hot
Tundra
Emotional keywords: Cold, brittle, bright, sparse, quick. Feels like: Full daylight on ice — pale ground, hard edges, nothing lingering.
This theme is NOT:
- Cosy — frost neutrals stay blue; never warm them toward gray-beige
- Slow — motion is the quickest of the set; don't soften it to feel premium
- Rounded — corners are near-square (factor 0.15); rounding them erases the theme
- Pastel — rose is a cold signal, not a soft accent
| Theme | Primary | Secondary | Neutrals | Motion | Radius |
|---|---|---|---|---|---|
| Plum | Plum ink | Amber | Warm gray (cream) | 140ms, hard stop | Cut (0.25x) |
| Proof | Process cyan | Process yellow | Cool proof stock | 140ms, hard stop | Cut (0.25x) |
| Substrate | Jade | Instrument amber | Graphite | 160ms, settling | Softened (0.4x) |
| Lava | Magma — yellow→red as it deepens | Sulfur yellow | Basalt | 140ms, hard stop | Cut (0.25x) |
| Tundra | Steel blue | Cold rose | Frost | 120ms, brittle | Square (0.15x) |
What themes may vary — and what they may not
Beyond color, themes may override three feel tokens: --motion-duration and --motion-ease (re-time every component transition that doesn't set an explicit duration/easing utility) and --radius-factor (scales every corner radius from a single multiplier). Use them sparingly. Plum, proof and lava all sit on the house baseline — a cut corner and a 140ms stop — because a theme should be recognisable across a room by its color; substrate slows and softens slightly, tundra goes quicker and squarer, and that spread is about as far as the feel tokens should travel.
Semantic token names, the spacing scale, and the typography scale stay constant across themes. Those are structural — part of the system's identity, not the brand's. If you find yourself needing per-theme spacing or typography, you've conflated brand identity with system structure; rethink the abstraction before forking it.