When I wrote about programming CSS to perform Sass colour functions I said this about the brilliant Lea Verou:
As so often happens when I’m reading something written by Lea—or seeing her give a talk—light bulbs started popping over my head (my usual response to Lea’s knowledge bombs is either “I didn’t know you could do that!” or “I never thought of doing that!”).
Well, it happened again. This time I was reading her post about hybrid positioning with CSS variables and max()
. But the main topic of the post wasn’t the part that made go “Huh! I never knew that!”. Towards the end of her article she explained something about the way that browsers evaluate CSS custom properties:
The browser doesn’t know if your property value is valid until the variable is resolved, and by then it has already processed the cascade and has thrown away any potential fallbacks.
I’m used to being able to rely on the cascade. Let’s say I’m going to set a background colour on paragraphs:
p {
background-color: red;
background-color: color(display-p3 1 0 0);
}
First I’ve set a background colour using a good ol’ fashioned keyword, supported in browsers since day one. Then I declare the background colour using the new-fangled color()
function which is supported in very few browsers. That’s okay though. I can confidently rely on the cascade to fall back to the earlier declaration. Paragraphs will still have a red background colour.
But if I store the background colour in a custom property, I can no longer rely on the cascade.
:root {
--myvariable: color(display-p3 1 0 0);
}
p {
background-color: red;
background-color: var(--myvariable);
}
All I’ve done is swapped out the hard-coded color()
value for a custom property but now the browser behaves differently. Instead of getting a red background colour, I get the browser default value. As Lea explains:
…it will make the property invalid at computed value time.
The spec says:
When this happens, the computed value of the property is either the property’s inherited value or its initial value depending on whether the property is inherited or not, respectively, as if the property’s value had been specified as the unset
keyword.
So if a browser doesn’t understand the color()
function, it’s as if I’ve said:
background-color: unset;
This took me by surprise. I’m so used to being able to rely on the cascade in CSS—it’s one of the most powerful and most useful features in this programming language. Could it be, I wondered, that the powers-that-be have violated the principle of least surprise in specifying this behaviour?
But a note in the spec explains further:
Note: The invalid at computed-value time concept exists because variables can’t “fail early” like other syntax errors can, so by the time the user agent realizes a property value is invalid, it’s already thrown away the other cascaded values.
Ah, right! So first of all browsers figure out the cascade and then they evaluate custom properties. If a custom property evaluates to gobbledygook, it’s too late to figure out what the cascade would’ve fallen back to.
Thinking about it, this makes total sense. Remember that CSS custom properties aren’t like Sass variables. They aren’t evaluated once and then set in stone. They’re more like let
than const
. They can be updated in real time. You can update them from JavaScript too. It’s entirely possible to update CSS custom properties rapidly in response to events like, say, the user scrolling or moving their mouse. If the browser had to recalculate the cascade every time a custom property didn’t evaluate correctly, I imagine it would be an enormous performance bottleneck.
So even though this behaviour surprised me at first, it makes sense on reflection.
I’ve probably done a terrible job explaining the behaviour here, so I’ve made a Codepen. Although that may also do an equally terrible job.
(Thanks to Amber for talking through this with me and encouraging me to blog about it. And thanks to Lea for expanding my mind. Again.)