Background
The fact that var() resolves on the element it is specified, while most other things (at least for unregistered custom properties) are passed around as token sequences and are interpreted at the point of usage is one of the things that trips people up a lot and creates a lot of bugs.
Example 1: Theme with dark mode
This is a very contrived minimal example to illustrate the problem, so please don't reply "but they can use light-dark() for color schemes!".
Suppose we have themes represented with .theme-* classes, which also specify their tokens on :root so that if no .theme-* class is used, the last theme included wins automatically. Each theme also has a .dark class, for dark mode (which is managed via JS).
:root,
.theme-foo {
--color-blue-95: #ebf4ff;
/* ... */
--color-blue-05: #00112f;
--color-blue: var(--color-blue-50);
/* "Semantic" color tokens */
--color-text: var(--color-blue-05);
--color-bg: var(--color-blue-95);
/* "Semantic" color token mappings */
--color-border: color-mix(in oklch, var(--color-bg) 70%, var(--color-blue));
}
@scope (.dark) {
&, .theme-foo {
--color-text: var(--color-blue-95);
--color-bg: var(--color-blue-10);
}
}
The author's mental model is that the --color-border declaration creates a binding, and if they override --color-bg anywhere, --color-border will be updated to match. I.e. that they can use the .dark class on an element, and everything will just adapt to their dark mode, while in reality, the binding for --color-border is done on :root, .theme-foo so it will always be pointing to the light mode values.
Fixing it requires definining the mapping in a union of all possible selectors that could override any of its constituent properties:
:root, .theme-foo, .dark {
/* "Semantic" color token mappings */
--color-border: color-mix(in oklch, var(--color-bg) 70%, var(--color-blue));
}
Demos:
Example 2: Sizing utility classes
Here’s another one:
:root {
/* Base styles */
--size-xs: var(--font-size-xs);
--size-s: var(--font-size-s);
--size-m: var(--font-size-m);
--size-l: var(--font-size-l);
font-size: var(--size);
}
.size-s {
--size: var(--size-s);
--size-smaller: var(--size-xs);
}
:root, /* Medium size is the default */
.size-m {
--size: var(--size-m);
--size-smaller: var(--size-s);
}
.size-l {
--size: var(--size-l);
--size-smaller: var(--size-m);
}
.callout {
/* Callouts should be generally larger */
--size-xs: var(--font-size-s);
--size-s: var(--font-size-m);
--size-m: var(--font-size-l);
--size-l: var(--font-size-xl);
}
Can you spot the bug? Unless an explicit .size-* class is used on an element, --size-smaller will be pointing to --size-s on :root (or the whatever the closest element with a .size-* class defines), but the author's mental model was that --size-smaller should adapt to whatever the current --size-* variables are on the current element.
Both examples are inspired from real recent examples of code I debugged. I cannot count how frequently people seem to hit this issue, and how much trouble they have debugging it.
Strawman
There are use cases where the current behavior works best (it's unclear to me whether they are the majority, but that ship has sailed). Can we have our cake and eat it too, i.e. have ways to get either behavior? I think so. All we need is a way to specify for one or more var() references to be late-resolving at the point of usage (essentially like a mini-mixin).
- Late-resolving would mean that rather than the current behavior,
var() would also be propagated as a token stream, just like every other token.
- This might need to only work for unregistered custom properties, and those with a syntax of
*, since those with a specific syntax are resolved at the point of specification anyway.
What makes the design tricky is that there are use cases for defining a bunch of late-resolving declarations, but also use cases for one-offs. Depending on what we want to target, the solution would have a different shape:
1. Target: Multiple declarations, and possibly even entire rules
- an @-rule containing the declarations (e.g.
@late {}, @lazy {}, @map {} etc)
- An empty @-rule, whose scope is its lexical block (and the whole stylesheet if specified outside any blocks)
- An inheritable property, e.g.
var-resolution: late. Though this kicks the can down the road about when references can be resolved, as they need to wait for selector matching, so it may be harder to implement.
2. Target: Whole values of individual declarations
- A !-annotation, e.g.
!late or !lazy
3. Target: Individual values
- A separate function, e.g.
var-lazy(), var-late() or just lazy()
Discussion
We probably don't need all three.
- Any of them can emulate all others (emulating 3 would involve extra declarations), just with more friction. If the function name can be shorter, that reduces the friction.
- I think a graceful fallback to the current behavior could really help adoption. 1.2 and 1.3 are the only ones that doesn't break in unsupporting browsers.
- The vast majority of use cases I have encountered are about many declarations (often dozens), so some version of 1 seems most useful.
- I suspect 3 is easiest to implement. Then perhaps some variant of 1 can be implemented as sugar over it.
Background
The fact that
var()resolves on the element it is specified, while most other things (at least for unregistered custom properties) are passed around as token sequences and are interpreted at the point of usage is one of the things that trips people up a lot and creates a lot of bugs.Example 1: Theme with dark mode
This is a very contrived minimal example to illustrate the problem, so please don't reply "but they can use
light-dark()for color schemes!".Suppose we have themes represented with
.theme-*classes, which also specify their tokens on:rootso that if no.theme-*class is used, the last theme included wins automatically. Each theme also has a.darkclass, for dark mode (which is managed via JS).The author's mental model is that the
--color-borderdeclaration creates a binding, and if they override--color-bganywhere,--color-borderwill be updated to match. I.e. that they can use the.darkclass on an element, and everything will just adapt to their dark mode, while in reality, the binding for--color-borderis done on:root, .theme-fooso it will always be pointing to the light mode values.Fixing it requires definining the mapping in a union of all possible selectors that could override any of its constituent properties:
Demos:
Example 2: Sizing utility classes
Here’s another one:
Can you spot the bug? Unless an explicit
.size-*class is used on an element,--size-smallerwill be pointing to--size-son:root(or the whatever the closest element with a.size-*class defines), but the author's mental model was that--size-smallershould adapt to whatever the current--size-*variables are on the current element.Both examples are inspired from real recent examples of code I debugged. I cannot count how frequently people seem to hit this issue, and how much trouble they have debugging it.
Strawman
There are use cases where the current behavior works best (it's unclear to me whether they are the majority, but that ship has sailed). Can we have our cake and eat it too, i.e. have ways to get either behavior? I think so. All we need is a way to specify for one or more
var()references to be late-resolving at the point of usage (essentially like a mini-mixin).var()would also be propagated as a token stream, just like every other token.*, since those with a specific syntax are resolved at the point of specification anyway.What makes the design tricky is that there are use cases for defining a bunch of late-resolving declarations, but also use cases for one-offs. Depending on what we want to target, the solution would have a different shape:
1. Target: Multiple declarations, and possibly even entire rules
@late {},@lazy {},@map {}etc)var-resolution: late. Though this kicks the can down the road about when references can be resolved, as they need to wait for selector matching, so it may be harder to implement.2. Target: Whole values of individual declarations
!lateor!lazy3. Target: Individual values
var-lazy(),var-late()or justlazy()Discussion
We probably don't need all three.