multi-theme systems

[check: …]
📖 Pair this live Tailwind demo with the companion guide (.md) — this page is the rendered ground truth. ↗ Sibling to tailwind: theme_inline (when @theme inline breaks cross-variable resolution in scoped setups). ↗ Builds on frontend/tailwind: responsive variants (dark mode & dark: — here we make dark mode CLASS-driven via @custom-variant).

one @theme, many palettes — the cascade trick

A single @theme block defines your tokens once and emits them to :root. Every utility it spawns reads the token through a variable: .bg-accent { background-color: var(--color-accent) }. That indirection is the superpower — override the variable under a scoped selector and the whole subtree re-skins, with zero utility recompilation. One palette becomes three (default / ocean / forest) by toggling data-theme; dark mode becomes class-driven by toggling .dark.

strategytriggerwhat changesrecompile?
data-theme palette set data-theme="ocean" on a container --color-accent / --color-surface for that subtree ❌ no — pure CSS cascade
class dark mode add .dark class on an ancestor tokens under .dark + every dark: variant ❌ no — @custom-variant dark is pre-compiled
media dark mode (default) OS prefers-color-scheme: dark only dark: variant utilities ❌ no
/* @theme emits tokens to :root and utilities read them via var() */ @theme { --color-accent: oklch(0.70 0.15 250); /* default palette */ --color-surface: oklch(0.25 0.03 250); } /* → .bg-accent { background-color: var(--color-accent); } (compiled once) */ /* Scoped override: same variable name, narrowed scope. Cascade does the rest. */ [data-theme="ocean"] { --color-accent: oklch(0.72 0.14 215); } [data-theme="forest"] { --color-accent: oklch(0.62 0.17 150); }

1 · brand palette switcher — data-theme re-skins the subtree

Click a preset. The stage below sets data-theme, which overrides --color-accent / --color-surface for its descendants. The swatches use bg-accent / bg-surface — the same compiled utilities in every theme; only the variable values cascade. The readout prints the live getComputedStyle values as proof.

accent token

class = bg-accent

surface token

class = bg-surface

composed card

text-accent on bg-accent

border + bg + text all token-driven

theme: default

--color-accent: —

bg-accent → —

2 · class-based dark mode — @custom-variant dark

By default dark: follows the OS prefers-color-scheme media query. Override the dark variant with @custom-variant dark (&:where(.dark, .dark *)) and now dark: utilities fire whenever a .dark class sits anywhere up the tree — fully script-toggleable, no media query. Toggle below and watch the dark: utilities (and the .dark token override) apply.

/* Compiled ONCE by the Play CDN — dark: now means ".dark ancestor" */ @custom-variant dark (&:where(.dark, .dark *));

Zero Gravity Pen

The same dark: utilities compiled once — they activate only when .dark is an ancestor. No media query involved.

primary neutral

Also: .dark overrides --color-surface (token scoping works for class themes too) — surface card below:

bg-surface (token) — follows .dark override

.dark class: absent (light)

3 · the scoped cascade — which selector wins, for whom

Custom properties cascade like any other CSS value: an element inherits the value from its nearest ancestor that declares it. :root (from @theme) is the global default; a closer [data-theme] or .dark shadows it for that subtree only.

selectorscopeeffect on descendants
:root (via @theme) entire document sets the global default for every --color-* token
[data-theme="ocean"] that subtree only shadows --color-accent/surface; siblings outside are untouched
[data-theme="forest"] that subtree only a second, independent palette — can coexist with ocean elsewhere on the page
.dark that subtree only shadows tokens AND triggers dark: variants (paired with @custom-variant)
[data-theme="ocean"].dark intersection both apply — later / more specific declaration wins per property

intent → pattern

intentpatternwhy
define global tokens @theme { --color-accent: oklch(0.7 0.15 250); } emits to :root + generates bg-accent reading var(--color-accent)
brand palette per area [data-theme="ocean"] { --color-accent: oklch(0.72 0.14 215); } same utility, cascaded value — no recompile, no dark: spam
switch at runtime stage.dataset.theme = "ocean" one attribute flip re-skins the subtree; CSS engine handles the rest
class-based dark mode @custom-variant dark (&:where(.dark, .dark *)); dark: now keys off .dark class, not the OS media query
data-attr dark mode @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); drive dark mode with data-theme="dark" instead of a class
dark token override .dark { --color-surface: oklch(0.16 0.01 250); } re-scope a token under dark — every bg-surface updates automatically
multiple palettes coexist two containers, different data-theme ocean sidebar + forest hero on the same page, same compiled CSS