CSSTidy CSS Formatter

CSS Custom Properties and Design Tokens: Fallbacks, @property, and What Does Not Cascade

CSSTidy — CSS Formatter & Minifier Guides · Updated 2026-10-12 · All guides

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 valuecolor: var(--x, red) gives
unset anywherered (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: nonsenseinvalid-at-computed-value-time: the property becomes unset for that element; your second fallback is not consulted
--x: initialthe guaranteed-invalid value; fallback is used
--x: ; inside a media query that did not matchunset; 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

  1. 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.
  2. 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.
  3. 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.

Developer Sponsor / Partner
Copied to clipboard!