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 { /* default light */ }
.dark { /* default 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.

What themes should NOT vary

Semantic token names, the spacing scale, typography scale, radii, and shadows all 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. Themes are color-only by design.