Custom Property Fallbacks and Invalid Values

Custom properties are the backbone of themable components and token-driven motion, and the second argument of var() — the fallback — looks like a safety net. It is a narrower net than most people assume. The fallback covers a missing property, not a wrong one: set --duration: fast by mistake and transition-duration: var(--duration, 200ms) does not use 200ms. It becomes invalid, and the property quietly reverts to its initial value, discarding every earlier declaration that would have worked. Understanding when fallbacks apply, what "invalid at computed-value time" means, and how registered properties change the picture prevents a whole category of mysteriously missing animations. This page belongs to CSS Custom Properties Architecture in the CSS-Only Micro-Interactions & Animations guide.

When the fallback applies

The fallback in var(--name, fallback) is used only when --name has no value — it was never declared on the element or its ancestors (for an inheriting property), or it was set to the initial keyword, which gives a custom property its "guaranteed-invalid" initial value. In every other case, the property's value is substituted, whether or not it makes sense where it lands.

Three outcomes for a var() reference A flow diagram. Is the custom property set? If no, the fallback is used. If yes, its value is substituted. Then: is the result valid for the property? If yes, it applies. If no, the declaration is invalid at computed-value time and the property behaves as unset, inheriting or taking its initial value; earlier declarations are not revived. The fallback covers "missing", not "wrong" Is --duration set? no: use fallback yes: substitute, then valid? valid: applies invalid: acts as unset earlier rules not revived

Invalid at computed-value time

A normal invalid declaration, such as transition-duration: fast;, is thrown away at parse time. The cascade never sees it, so an earlier valid declaration wins. A declaration that contains var() cannot be checked at parse time, because the browser does not yet know what will be substituted. It is assumed valid, it wins the cascade, and only later — at computed-value time — does the browser discover that fast is not a time. At that point there is no going back to the losing declarations. The specification's answer is to treat the property as unset: inherited properties such as color inherit from the parent, and non-inherited ones such as transition-duration take their initial value, 0s.

The complete implementation

The demo shows three buttons. The first gets a valid duration token, the second has no token and uses the fallback, and the third has a typo'd token that silently kills its transition. Hover each to compare.

Live demoValid token, missing token, typo
Hover each button. The first uses its 250ms token, the second falls back to 600ms, and the third — with the token set to "fast" — has no transition at all.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>var() fallbacks and invalid values</title>
<style>
  body { font: 15px/1.5 system-ui, sans-serif; margin: 2rem; display: flex; gap: 1rem; flex-wrap: wrap; }

  .btn {
    padding: 0.75rem 1.25rem;
    border: 0;
    border-radius: 8px;
    background: #2563eb;
    color: #fff;
    font: inherit;
    /* An earlier, valid declaration ... */
    transition: background-color 400ms ease-out, translate 400ms ease-out;
    /* ... is overridden by this one, whatever --motion-duration turns out to be. */
    transition-duration: var(--motion-duration, 600ms);
  }
  .btn:hover { background: #7c3aed; translate: 0 -4px; }

  .btn--token   { --motion-duration: 250ms; }   /* valid: 250ms */
  /* .btn--missing sets nothing: the fallback 600ms applies */
  .btn--typo    { --motion-duration: fast; }    /* invalid at computed-value time: 0s */
</style>
</head>
<body>
  <button class="btn btn--token" type="button">Token: 250ms</button>
  <button class="btn btn--missing" type="button">Missing: fallback</button>
  <button class="btn btn--typo" type="button">Typo: no transition</button>
</body>
</html>

The third button is the trap. Its author probably expected either the fallback (600ms) or the shorthand's 400ms. It gets neither: the transition-duration declaration wins the cascade, becomes invalid after substitution, and resets to 0s. The button changes colour instantly and there is no error anywhere.

The key technique: validate at the boundary

Because fallbacks do not protect against wrong values, protection has to come from elsewhere. Three approaches work, from strongest to lightest:

  1. Register the property. @property --motion-duration { syntax: "<time>"; inherits: true; initial-value: 200ms; } makes the browser check every value against the <time> syntax. --motion-duration: fast no longer produces a broken transition-duration; instead the custom property itself is treated as unset, so it inherits its parent's valid duration or takes the registered initial-value of 200ms. The typo still needs fixing, but the damage is contained to one sensible default instead of a silent 0s. Registered Properties and Type Safety covers syntax strings in depth.
  2. Keep tokens in one place. If every duration token is defined in a single :root block with valid values, and components only ever reference tokens rather than set them, typos have one place to hide.
  3. Use the fallback for its real job. Fallbacks are ideal for optional component APIs: padding: var(--card-padding, 1rem) lets a parent customise the padding but works without it. They are the right tool for "may be missing" and the wrong one for "may be wrong".
Registration guarantees a value of the right type Left panel, unregistered: --motion-duration fast is accepted, substituted into transition-duration, found invalid, and the property resets to 0 seconds. Right panel, registered with syntax time and initial-value 200ms: fast fails the syntax check, so the custom property falls back to its inherited value or 200ms and transition-duration is 200ms. Same typo, different outcome unregistered registered as <time> --motion-duration: fast accepted as a token string substituted, then invalid duration: 0s --motion-duration: fast fails the <time> check inherited or initial-value duration: 200ms A registered property can never hold a value that fails its syntax.

Where this bites animations hardest

Motion code is unusually exposed to invalid substitution because so much of it is assembled from tokens. A few patterns come up repeatedly:

  • Unitless numbers in times. --stagger: 80 multiplied into transition-delay: calc(var(--i) * var(--stagger)) produces a number, not a time, so the delay is invalid and every item animates at once. Store the token with its unit, 80ms, or multiply by 1ms inside the calc().
  • Keywords where values are expected. Design tokens exported from tools sometimes carry names such as slow or standard rather than the values those names stand for. Each one produces a silent 0s or a default easing.
  • Whole shorthands in one token. --enter: 300ms ease-out works inside transition: opacity var(--enter) but not inside transition-duration: var(--enter), which cannot accept an easing. Keep duration and easing tokens separate so each fits every property that uses it.
  • Empty values. --delay: ; is a valid, empty custom property value, not a missing one, so the fallback is skipped and the referencing declaration becomes invalid.

In each case the element still renders, which is why these bugs survive review. A quick check in the Computed pane, or a registered property for each motion token, catches them early.

Nested fallbacks and fallback lists

A fallback can itself contain var(), which builds a chain: var(--button-duration, var(--motion-duration, 200ms)). The browser tries the component-level token, then the global token, then the literal. This is a clean way to let components override a site-wide value. Keep chains to two levels; deeper nesting becomes hard to reason about and hard to debug in DevTools, which shows only the final computed value.

Everything after the first comma is the fallback, commas included. var(--shadow, 0 1px 2px black, 0 4px 8px black) has a fallback of two shadows, which is convenient for list-valued properties like box-shadow, transition and font-family.

Debugging invalid substitutions

DevTools makes invalid-at-computed-value-time declarations visible if you know where to look. In the Styles pane, the declaration appears normal — not struck through — because it was valid at parse time. Switch to the Computed pane and the property shows its initial value, with the winning declaration listed as its source. That mismatch, "the rule that wins says var(--x, 600ms) but the computed value is 0s", is the signature of the problem. Once you have seen it a couple of times, it becomes the first thing to check whenever a token-driven animation stops working. Hovering the var() reference in Chromium shows the substituted value, which usually reveals the typo immediately.

Browser support

Custom properties and var() fallbacks are supported in every current browser. @property is supported in Chrome and Edge 85+, Firefox 128+ and Safari 16.4+; browsers without it treat the property as unregistered, which returns to the default behaviour described above. The transition shorthand is supported in Chrome 26+, Edge 12+, Firefox 16+ and Safari 9+, and the standalone translate property in Chrome and Edge 104+, Firefox 72+ and Safari 14.1+.

FAQ

When is the fallback in var() used? Only when the custom property is not set, or is set to the initial guaranteed-invalid value, for example with the initial keyword. If the property holds any value, even one that makes no sense for the property using it, the fallback is ignored.

What does invalid at computed-value time mean? The declaration looked valid when the stylesheet was parsed because it contained var(), but after substitution the value is invalid for the property. The browser cannot go back to an earlier declaration, so the property behaves as unset: inherited properties inherit and others take their initial value.

Why does a bad custom property value not fall back to my earlier declaration? The cascade has already chosen the declaration containing var() before substitution happens. When that value turns out invalid, the earlier declaration is no longer in play. Register the property with @property and an initial value, or keep tokens validated in one place, to guard against it.

How does @property change fallback behaviour? A registered property checks values against its syntax. A value that does not match makes the custom property itself fall back to its inherited value or its registered initial-value, so anything referencing it always receives a value of the right type. That makes it much more predictable.

Related articles

More pages in the same section.