Controlling when CSS custom properties are computed
When and how CSS custom properties are calculated might not be what you expect, and it has a big impact on your styles.
The default
Take this HTML:
<div></div><div class="second-item"> <p>…</p></div>And this CSS:
.second-item { --index: sibling-index(); & > :first-child { scale: var(--index); }}If you're unfamiliar with sibling-index(), here's a quick video intro I posted recently:
The question is, how much is the paragraph going to scale?
sibling-index() is 1-indexed, but is it going to pick up the index of the second div - where the custom property is defined, or its first child - where the custom property is used? The answer is: it depends.
If the code is taken in isolation, the value of the custom property will be stored as a token stream, so it's computed a little like this:
.second-item { --index: sibling-index(); /* …Which is computed to: */ --index: sibling-index(); /* (as in, no change) */ & > :first-child { scale: var(--index); /* …Which is substituted for: */ scale: sibling-index(); /* …Which computes to: */ scale: 1; }}So the var() is substituted for sibling-index(), which is evaluated in that context. Since the paragraph is the first child of its parent, it will scale by 1.
This is reflected in getComputedStyle():
const div = document.querySelector('.second-item');console.log(getComputedStyle(div).getPropertyValue('--index'));// Logs: "sibling-index()"But that's just the default…
Changing the default
Let's register that custom property:
@property --index { syntax: '<number>'; inherits: true; initial-value: 0;}Custom properties you haven't registered behave as if they have syntax: '*', meaning 'anything', which computes to a token stream as before. But here we're using '<number>', meaning it computes the value as a number:
.second-item { --index: sibling-index(); /* …Which is computed to: */ --index: 2; & > :first-child { scale: var(--index); /* …Which is substituted for: */ scale: 2; /* …Which computes to: */ scale: 2; }}Registering the custom property & giving it a syntax meant the value was computed earlier, in a different context, so the result was different.
Again, this is reflected in getComputedStyle():
const div = document.querySelector('.second-item');console.log(getComputedStyle(div).getPropertyValue('--index'));// Logs: "2"And it's not just sibling-index() that behaves this way. This also impacts relative font units (em, ex, ch, cap, lh etc.), container query units (cqw, cqi, cqb etc.), and more.
Another thing that's impacted, although slightly differently, is URLs. In CSS, URLs are generally resolved against the stylesheet's base URL at computed-value time - so if a token-stream custom property is defined in one stylesheet and var()'d from another, the URL resolves against the consuming stylesheet. Registering the custom property with syntax: '<url>' pins it to the stylesheet that declared it.
But what about var()?
Here's a trickier example:
.second-item { --multiplier: 1; --index: calc(sibling-index() * var(--multiplier)); & > :first-child { --multiplier: 3; scale: var(--index); }}What happens here? Well, var() behaves differently to sibling-index(). var() is an "arbitrary substitution function", along with a bunch of other functions like if(), attr(), and ident(). These are substituted immediately, regardless of the syntax of the custom property. So:
.second-item { --multiplier: 1; --index: calc(sibling-index() * var(--multiplier)); /* …Which is substituted for: */ --index: calc(sibling-index() * 1);}What happens next depends on the defined syntax of --index. It'll either stay as the above token stream, or it'll compute down to 2 - the same rules as before. In both cases, --multiplier: 3 on the child is ignored.
This was a detail of CSS that I sort-of half-knew, as in, I knew @property changed computation timing somehow, but I hadn't fully understood when or how, until I looked it up today. I hope you understand it now too!
