regular @theme → :root · @theme inline → element
A regular @theme block emits CSS variables to :root,
and utilities reference them with var(). The catch: if your
theme value itself contains a var() — like
--color-x: var(--brand) — that inner reference is resolved
ONCE at :root computed-value time, then the
result is inherited down the tree. Change --brand on a child
and the utility does not follow. @theme inline flips
this: it skips emitting the :root var and inlines the value into each
utility, so var(--brand) resolves at the element that
actually uses the class — picking up whatever --brand is in
scope there. Dynamic theming works.
| aspect | @theme | @theme inline |
|---|---|---|
emits --color-x to :root? |
✅ yes — utility is background: var(--color-x) |
❌ no — value is inlined into the utility directly |
| generated utility | .bg-x { background-color: var(--color-x) } |
.bg-x { background-color: var(--brand) } (value inlined) |
where the inner var() resolves |
at :root (computed once, result inherited) | at the element using the utility (live, per-scope) |
| best for | static values — oklch colors, fixed spacing, font stacks | values that reference OTHER css vars — dynamic themes, JS control |
does descendant --brand override propagate? |
❌ no — frozen to :root's value | ✅ yes — utility reads the element's scope |
1 · toggle --brand on the container — watch which swatch moves
Both swatches below are inside the same demo container. The container's
--brand starts as the inherited :root default
(slate gray). Click Set --brand = cyan to override
--brand on the container only. The
bg-dynamic swatch follows
(resolves var(--brand) at the element). The
bg-broken swatch does
not — its value was frozen at :root.
compiled: background: var(--color-broken)
--color-broken was resolved at :root → frozen.
compiled: background: var(--brand)
var(--brand) resolves at this element → live.
--brand(container): — · bg-broken: — · bg-dynamic: —
2 · why regular @theme freezes (the cascade mechanism)
CSS custom properties are inherited as computed values. When
--color-broken: var(--brand) is declared on
:root, the browser computes --color-broken by
substituting var(--brand) using the value of
--brand at :root. The substituted result is then
inherited. A descendant that overrides --brand is too late —
its inherited --color-broken is already the frozen
:root-resolved value.
| step | @theme (broken) | @theme inline (dynamic) |
|---|---|---|
| 1. define token | --color-broken: var(--brand) → written to :root |
value var(--brand) marked for inlining; no :root var |
| 2. compile utility | .bg-broken { bg: var(--color-broken) } |
.bg-dynamic { bg: var(--brand) } ← inlined |
| 3. compute at :root | --color-broken resolves var(--brand) → slate; inherited as slate |
nothing to compute at :root — no var emitted |
| 4. element uses utility | reads inherited (frozen) --color-broken → slate |
reads --brand in its own scope → cyan |
5. descendant overrides --brand |
❌ ignored — inherited value already settled | ✅ honored — resolution is at the element |
3 · when to use which · css var chaining
Rule of thumb: if the value is a literal (oklch, hex, px, a font
stack), use @theme. If the value references another css
variable — especially one that changes per data-theme,
per component, or via JS — use @theme inline.
| scenario | use | why |
|---|---|---|
| brand color = literal oklch value | @theme |
static — emit to :root, cache forever |
brand color = var(--user-brand) set by JS at runtime |
@theme inline |
must resolve at element so JS updates are picked up |
[data-theme="dark"] swaps --bg, utilities follow |
@theme inline |
per-scope resolution — dark mode overrides cascade into utilities |
| spacing scale = fixed rem values | @theme |
static — never changes |
component-local --accent bridged into bg-accent |
@theme inline |
each component sets its own --accent; utilities follow locally |
aliasing one token to another (--color-link: var(--color-brand)) |
@theme inline |
so redefining --color-brand later propagates to --color-link |
CSS var chaining: with
@theme inline, a chain like
--color-link → var(--color-brand) → var(--brand) resolves
link-by-link at the element, so the leaf (--brand) is live.
With regular @theme, the whole chain collapses at :root on
first compute — the leaf is baked in and later overrides are invisible.
intent → pattern
| intent | pattern | why |
|---|---|---|
| static brand color (literal) | @theme { --color-brand: oklch(0.7 0.15 250); } |
emitted to :root; bg-brand = var(--color-brand) |
| bridge an external css var | @theme inline { --color-brand: var(--user-brand); } |
bg-brand inlines to var(--user-brand) — resolves at element |
| JS-controlled theme | JS sets el.style.setProperty('--user-brand', cyan) |
with @theme inline, bg-brand on descendants updates live |
| data-theme palettes | [data-theme="dark"] { --bg: #000 } + @theme inline { --color-bg: var(--bg) } |
utilities follow whichever theme scope the element is in |
| alias one token to another | @theme inline { --color-link: var(--color-brand); } |
keeps the alias live — editing the source token propagates |
| check resolution scope at runtime | getComputedStyle(el).backgroundColor |
if it changes when you set --brand on a child → inline worked |