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.
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.
<!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:
- 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: fastno longer produces a brokentransition-duration; instead the custom property itself is treated as unset, so it inherits its parent's valid duration or takes the registeredinitial-valueof 200ms. The typo still needs fixing, but the damage is contained to one sensible default instead of a silent0s. Registered Properties and Type Safety covers syntax strings in depth. - Keep tokens in one place. If every duration token is defined in a single
:rootblock with valid values, and components only ever reference tokens rather than set them, typos have one place to hide. - 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".
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: 80multiplied intotransition-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 by1msinside thecalc(). - Keywords where values are expected. Design tokens exported from tools sometimes carry names such as
sloworstandardrather than the values those names stand for. Each one produces a silent0sor a default easing. - Whole shorthands in one token.
--enter: 300ms ease-outworks insidetransition: opacity var(--enter)but not insidetransition-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
- CSS Custom Properties Architecture — the parent guide.
- Registered Properties and Type Safety — syntax strings and initial values.
- Fluid Spacing Tokens Driving Transition Durations — tokens that must stay valid.
- Transition Shorthand and Multiple Properties — how longhands override the shorthand.
Related articles
More pages in the same section.