Custom properties as plumbing
Not variables. Values that inherit, substitute late, and have no type unless you give them one, which explains every surprise they produce.
A Sass variable is compiled away before the browser sees it. A custom property is a real value in the cascade, it inherits, it can be changed at runtime, it can differ per subtree, and JavaScript can read and write it. Those are different tools with the same shape, and confusing them is where the trouble starts.
Work the instrument below in order, because the three panels are increasingly uncomfortable and the third is the one worth the chapter.
The first panel is the easy half: tokens at :root, components reading them, one change re-theming everything. Then tick the subtree box and watch one declaration on one wrapper re-theme only what is inside it. That is inheritance doing exactly what it always does, and it is what makes per-section theming a one-liner, and what makes a token that is "wrong in one corner of the app" so hard to track down. The value is not global. It is wherever you last set it.
The second panel is the trap. Custom properties are substituted at computed-value time, which is after the cascade has already picked a winner. So a var() that fails does not lose gracefully to the previous declaration the way an ordinary typo does, it makes the whole declaration invalid at computed-value time, and the property falls to its inherited-or-initial value. Typo a colour token and your button is not the old colour, it is transparent. Try all four states in the panel; the last one, where a fallback is present and still does not save you, is the one that ends arguments.
That last state is worth stating plainly: var(--x, fallback) uses the fallback when --x is not set. Not when it is set to something wrong. It is a presence check, not a validity check, and a token holding a nonsense value sails straight past it.
The third panel is @property, which is the fix for all of that. Register a custom property with a syntax and the browser knows its type: a wrong value is rejected at parse time instead of poisoning a declaration later, and, the part people actually want, the value can be interpolated, so a registered <angle> or <color> can be animated where an unregistered one snaps.
Custom properties, including the parts that bite
Three components, one set of tokens, no component-specific CSS. This is the half every tutorial shows, and the half that never causes a bug. Tick the box below to start the half that does.
.button { background: var(--brand); }
The button is the brand colour. --brand is set and valid, so the substitution succeeds and the declaration behaves like any other.
/* no @property rule */
:root { --sweep: 0deg; }Unregistered, --sweep is just a token sequence. The browser cannot interpolate between "0deg" and "360deg" because as far as it knows those are strings, so any animation of it snaps. It also cannot reject a nonsense value, which is how panel 2 happens.
The useful mental model: a custom property is a value that inherits, substituted late, with no type unless you give it one. Every surprise in this component follows from one of those three.
Now build. Convert your own project’s stylesheet to tokens: colour, spacing, radius, type scale. Add a dark theme by redefining the tokens under [data-theme="dark"] and nothing else, if you find yourself writing a second rule for a component, your tokens are not granular enough. Then scope a token on one section to prove subtree theming works. Then deliberately typo one and watch what happens, so you have seen it once in your own code.
Build this in your own editor
This one runs on your machine rather than in the browser workbench. Work to the outcomes below, then come back and mark it complete.
Worth reading
- Kevin Powell: An introduction to CSS custom properties ↗, Good on the difference from preprocessor variables, which is the part that matters.
- web.dev: Custom properties ↗, Short written reference including the invalid-at-computed-value-time rule.
You should now be able to
- Build a theme with custom properties and switch it at runtime
- Scope a token to a subtree and know why that works
- Explain what happens when a var() is invalid, and why it is not what you expect
- Use @property to give a token a type
Loading…