Theming API
Define named themes in TypeScript, register them, and swap between them at runtime. A theme is a map of semantic tokens — every component that uses those tokens re-colours itself.
Example
createTheme builds a token map, ThemeRegistry owns which one is active, and ThemeSelect switches between them. Nothing else in your app changes — components already read these tokens.
data-theme="ocean" · 2 registered
Extending a theme
extendsstarts from another theme's tokens, so a variant only names what actually differs. The source theme is never mutated.
const moss = createTheme({
name: "moss",
extends: ocean,
tokens: { ring: "oklch(0.60 0.12 148)" },
light: { primary: "oklch(0.43 0.11 148)" },
dark: { primary: "oklch(0.74 0.13 148)" },
})Shipping themes as CSS
ThemeRegistry injects theme CSS at runtime so a new theme works with no build changes. Static CSS is faster — it is there on the first paint — so for themes you know at build time, write themeToCss's output into your stylesheet and pass injectCss={false}.
[data-theme="ocean"] {
--ring: oklch(0.62 0.13 232);
--primary: oklch(0.45 0.12 232);
--primary-border: oklch(0.30 0.09 232);
--accent: oklch(0.93 0.04 232);
--accent-foreground: oklch(0.35 0.10 232);
--border-accent: oklch(0.52 0.12 232);
--accent-fill: oklch(0.45 0.12 232);
--accent-fill-hover: oklch(0.38 0.11 232);
}
[data-theme="ocean"].dark,
.dark [data-theme="ocean"] {
--ring: oklch(0.62 0.13 232);
--primary: oklch(0.72 0.13 232);
--primary-foreground: oklch(0.20 0.04 232);
--primary-border: oklch(0.85 0.09 232);
--accent: oklch(0.32 0.07 232);
--accent-foreground: oklch(0.90 0.05 232);
--border-accent: oklch(0.66 0.12 232);
--accent-fill: oklch(0.72 0.13 232);
--accent-fill-hover: oklch(0.79 0.12 232);
}Avoiding a flash on first load
The stored theme is only readable on the client, so anything that runs after hydration shows the default palette first. themeInitScript() returns the source of a tiny script that applies the stored theme before first paint — run it inline in your document head.
// app/layout.tsx — a server component
import { themeInitScript } from "substrateui"
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript() }} />
</head>In the Next.js App Router, everything SubstrateUI exports is a client module, so themeInitScript() cannot be called during a server render. Paste its output as a literal string in that case — it is a fixed snippet, and the only thing you would change is the storage key.
Theming a subtree
scoped puts data-theme on a wrapper element instead of the document element, which is what you want for a preview pane, an embedded widget, or a section that deliberately breaks from the rest of the page. Generated CSS targets both placements, so dark mode keeps working inside a scoped subtree.
<ThemeRegistry themes={[ocean]} defaultTheme="ocean" scoped persist={false}>
<PreviewPane />
</ThemeRegistry>API Reference
ThemeRegistry
| Prop | Type | Default | Description |
|---|---|---|---|
themes | Theme[] | [] | Themes made available to useTheme and ThemeSelect. |
defaultTheme | string | "default" | Applied when nothing is stored. "default" means the stylesheet's built-in palette. |
storageKey | string | "substrateui-theme" | localStorage key for the persisted choice. |
persist | boolean | true | Remember the choice across reloads. |
injectCss | boolean | true | Inject themeToCss(themes) into the head. Turn off if you ship it in your stylesheet. |
scoped | boolean | false | Theme a subtree: render a wrapper carrying data-theme and leave the document element alone. |
className | string | — | Classes for the wrapper element rendered when scoped is set. |
createTheme(config)
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | Becomes the data-theme value — letters, digits, - and _ only. |
label | string | title-cased name | Human-readable name, shown by ThemeSelect. |
extends | Theme | — | Start from another theme's tokens and override them. |
tokens | ThemeTokens | — | Tokens applied to both colour schemes. |
light | ThemeTokens | — | Light-scheme tokens, overriding tokens. |
dark | ThemeTokens | — | Dark-scheme tokens, overriding tokens. |
ThemeSelect
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Theme" | Accessible name for the trigger. |
baseLabel | string | "Default" | Label for the built-in palette option. |
includeBase | boolean | true | Offer the built-in palette alongside the registered themes. |
className | string | — | Classes for the select trigger. |
useTheme() returns { theme, setTheme, themes, resolvedTheme } and throws outside a ThemeRegistry. ThemeTokens autocompletes every semantic token name but accepts any custom property, so raw palette steps work too.
Accessibility
Themes are colour only — no component behaviour changes — so the burden is on the tokens you pick. Run the bun run audit:contrast check against your own theme before shipping it; the built-in palettes clear WCAG AA on every pairing, and a custom theme has no such guarantee. ThemeSelect renders a labelled select and holds its layout with a same-size placeholder until the stored theme is known, so the first paint does not shift.