Skip to content
Foundations

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.

Ocean
Badge

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

PropTypeDefaultDescription
themesTheme[][]Themes made available to useTheme and ThemeSelect.
defaultThemestring"default"Applied when nothing is stored. "default" means the stylesheet's built-in palette.
storageKeystring"substrateui-theme"localStorage key for the persisted choice.
persistbooleantrueRemember the choice across reloads.
injectCssbooleantrueInject themeToCss(themes) into the head. Turn off if you ship it in your stylesheet.
scopedbooleanfalseTheme a subtree: render a wrapper carrying data-theme and leave the document element alone.
classNamestringClasses for the wrapper element rendered when scoped is set.

createTheme(config)

PropTypeDefaultDescription
namestringBecomes the data-theme value — letters, digits, - and _ only.
labelstringtitle-cased nameHuman-readable name, shown by ThemeSelect.
extendsThemeStart from another theme's tokens and override them.
tokensThemeTokensTokens applied to both colour schemes.
lightThemeTokensLight-scheme tokens, overriding tokens.
darkThemeTokensDark-scheme tokens, overriding tokens.

ThemeSelect

PropTypeDefaultDescription
labelstring"Theme"Accessible name for the trigger.
baseLabelstring"Default"Label for the built-in palette option.
includeBasebooleantrueOffer the built-in palette alongside the registered themes.
classNamestringClasses 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.