CSS Custom Properties and Design Tokens: Fallbacks, @property, and What Does Not Cascade
Custom properties are not variables in the programming sense. They are inherited, computed-at-use-time declarations, and every surprising behaviour of them follows from that.
Fallback chains, resolved properly
.card { border-color: var(--accent, var(--brand, #58a6ff)); }
The fallback is used when --accent is invalid at computed-value time or unset — not when it is invalid at used time. That distinction is the whole "my fallback does not fire" bug class:
--x value | color: var(--x, red) gives |
|---|---|
| unset anywhere | red (fallback used) |
--x: (empty declaration) | nothing useful — var() fails, declaration becomes invalid at computed-value time, so color falls back to inherited, not to your fallback in some engines |
--x: nonsense | invalid-at-computed-value-time: the property becomes unset for that element; your second fallback is not consulted |
--x: initial | the guaranteed-invalid value; fallback is used |
--x: ; inside a media query that did not match | unset; fallback used |
The two rules that keep you out of trouble: register properties you rely on (below), and prefer var(--x, fallback) one level deep rather than nesting fallbacks three high — a chain like var(--a, var(--b, var(--c, red))) is unreadable at review time and hides which token is missing.
A guaranteed-invalid value is what initial gives you, and it is the cleanest way to turn a token off for a subtree: .legacy { --shadow: initial; }.
Inheritance is the feature and the footgun
:root { --space: 1rem; }
.parent { /* sets nothing */ }
.child { padding: var(--space); } /* 1rem, inherited from :root */
A custom property set on .sidebar reaches every descendant, including portalled modals rendered inside it — so a theme token set on a container silently restyles a dialog that is logically elsewhere. The reverse also happens: a component that assumes --radius exists on itself breaks the moment someone moves it under a scope that redefines it. Rule for components: define a local default — --radius: var(--radius-card, 8px) — so the component reads the theme without depending on it.
Custom properties do not animate — unless you register them
/* does not animate: the value is a string, interpolated at computed-value time */
.progress { --w: 0%; transition: --w 1s; } /* jumps */
/* animates: registered with a type and an initial value */
@property --w { syntax: '<percentage>'; inherits: false; initial-value: 0%; }
.progress { --w: 0%; transition: --w 1s; } /* smooth */
Registering gives the browser a typed property it can interpolate, and also makes it participate in the cascade with a real initial value. Until registered, a custom property is substituted text — which is why transition: --x 1s has never worked and why @keyframes animating --x fires in steps rather than smoothly. Types that are worth registering: <length>, <number>, <percentage>, <color>, and <length-percentage> for anything you want to tween. syntax: '*' keeps the loose behaviour — it fixes nothing but lets you validate the value's shape.
Token layout that survives a theme switch
Three layers, one direction of dependency:
/* 1. primitives — values, no meaning */
:root { --gray-900: oklch(24% .02 255); --blue-500: oklch(62% .17 250); }
/* 2. semantic tokens — meaning, no literals */
:root { --surface: var(--gray-900); --text: var(--gray-50); --accent: var(--blue-500); }
/* 3. components — read semantics only */
.card { background: var(--surface); color: var(--text); border-color: var(--border); }
A theme is then a redefinition of layer 2 only:
:root { color-scheme: light dark;
--surface: light-dark(#ffffff, oklch(18% .01 255));
--text: light-dark(#1a1f26, oklch(93% .01 255)); }
Two rules that prevent the classic token rot: components never reference a primitive directly (that is what makes dark mode a patchwork), and semantic names describe role, not appearance — --text-danger ages well, --text-red becomes a lie the day you add an amber warning.
Where custom properties beat a preprocessor variable
calc() with a per-instance override, no rebuild, in the browser:
.stack > * + * { margin-block-start: calc(var(--space-3) * var(--stack-scale, 1)); }
.card--dense { --stack-scale: .5; } /* one class, no new CSS */
.grid { grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr)); }
Sass cannot do the --cols line per element; it computes at build time and you get one class per column count. That is also the honest limit: custom properties cost a substitution at compute time per use, so a 4,000-node page with 12 var() lookups each is a real — small — cost, and it is not the reason a page is slow.
Three failure modes worth recognising
- The value is not where you think. DevTools → Computed → Show all lists every custom property on the element with the rule that set it. This is the only reliable way to debug a token, because the Styles panel shows the declaration that uses it, not the one that defines it.
- A token holding a whole declaration list.
--reset: margin: 0; padding: 0;does not expand into declarations — custom properties are values. That trick only works inside a mixin in a preprocessor, and the people selling it are selling Sass. - A token used in a media query.
@media (width > var(--bp))is invalid; media features cannot read custom properties, so breakpoints must stay static or move to container queries.
Related: resets and @layer for the cascade layers your tokens live in, and the cheat sheet for the light-dark()/color-mix() colour functions that make a two-line theme possible.