Skip to content

[css-variables-2] Lazy / Late resolving variable mappings #11543

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
LeaVerou opened this issue Jan 20, 2025 · 6 comments
Open

[css-variables-2] Lazy / Late resolving variable mappings #11543

LeaVerou opened this issue Jan 20, 2025 · 6 comments

Comments

@LeaVerou
Copy link
Member

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

  1. an @-rule containing the declarations (e.g. @late {}, @lazy {}, @map {} etc)
  2. An empty @-rule, whose scope is its lexical block (and the whole stylesheet if specified outside any blocks)
  3. 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.
@Loirooriol
Copy link
Contributor

Duplicate of #2749? There are also use cases for deferring things different than var(), see #9612

@LeaVerou
Copy link
Member Author

LeaVerou commented Jan 21, 2025

#2749 is more general, but they're definitely related. This makes me think that perhaps what we need is a lazy() function that can wrap entire values (i.e. for a late-resolving variable, you'd do lazy(var(--foo))), with sugar for wrapping all values of a set of declarations. It would be more verbose, but also a lot more flexible.

I suspect lazy values are easier to design & implement than eager values, but down the line we could also have syntax for the opposite (with a way to provide type). But that's much trickier to design.

@LeaVerou
Copy link
Member Author

Another day, another bug report rooted in this.

User: "Why doesn't my code work to use a larger shadow with a bigger spread?"

Their code:

wa-avatar {
    box-shadow: var(--wa-shadow-l);
    --wa-shadow-spread-l: 2.0rem;
}

The relevant design tokens:

:where(:root),
:host {
  --wa-shadow-spread-scale: -0.5;
  --wa-shadow-spread-l: calc(var(--wa-shadow-spread-scale) * 0.5rem);

  --wa-shadow-l: var(--wa-shadow-offset-x-l) var(--wa-shadow-offset-y-l) var(--wa-shadow-blur-l)
    var(--wa-shadow-spread-l) var(--wa-color-shadow);
}

Without including every possible mapping in every component's shadow root (so that :host applies), the mapping is only performed in :root and thus overriding it has no effect.

And even if it were included in every shadow root, this does not help with any other element.

Including every mapping on * is the only real solution, but that is a very heavyweight solution AND cancels inheritance.

Ideally, it should be possible for people to set either of these tokens at any level of the hierarchy, and have --wa-shadow-l just adapt unless its own value is explicitly overridden.

@tabatkins
Copy link
Member

The issue with lazy() or similar solutions is that it's not clear when it should be resolved. Given an ancestor chain A>B>C, one could set a --foo: lazy(var(--dep) + 1): on A, then --bar: var(--foo) / 2; on B, and then width: calc(1px * var(--bar)) on C. On which element is the lazy() resolved - B or C? If --dep is 1 on A, 10 on B, and 100 on C, what is the final value of width?

We could define that it's whenever you substitute into a "real" (non-custom) property. But that seems (imo) to unfairly demonize custom properties. They could be used as part of a library, where (to the page author) they're just as meaningfully "baseline" as CSS-defined properties.

We could define that it's whenever you substitute into a property with a registered (non-universal) grammar, so custom properties can opt themselves into resolving late() by given themselves a grammar. But not all property grammars are expressible in <syntax>, so that doesn't seem ideal. But that might be the best way to go about it, hrm.

Finally, I think it's probably reasonable to sometimes want some of the vars in an expression to be resolved immediately, while others are resolved later.


On the other hand, we could just say "if you want to define a computation that's resolved at some later time, use custom functions". The evaluation time for custom functions is explicit; it's when they're called. It means there's a bit of a syntax split, but that's something of what we're doing anyway with lazy().

It's definitely a bit more fiddly to have to define a (global!) function to defer a calculation, but "inline" functions are on the possible roadmap anyway. And they solve the "some evaluated now, some evaluated later", as we've discussed in a custom function issue - reusing the now-dropped using syntax to distinguish between definition-time references and execution-time references.

I was trying to sketch one out, but my brain is too fried from WG meetings to provide a realistic example right now. Something to work thru, tho.

@mwsundberg
Copy link

mwsundberg commented Mar 7, 2025

Would a syntax like --custom-property: var(--custom-property); be good to indicate that the value should be reevaluated in the current scope? I've often wished that there were a way to update a custom property yet not override any previously set value, which I imagine could use the same syntax: --custom-property: var(--custom-property, <new-default>);, though that's probably beyond the scope of this issue's topic.

I know that goes against the cycle detection rules, yet I figure it might be worth carving out an edge-case for self-referential loops without any other loop-members or mutations. That'd ideally have --custom-property: calc(var(--custom-property) / 2); and --one: var(--two); --two: var(--one); both still deemed invalid but --custom-property: var(--custom-property, <new-default>); would work. The parser (?) (evaluator? I'm unsure on terms) rules could even be fairly strict, only allowing --x: var(--x, [optional yet not --x dependent]); syntax specifically, as to not interfere with the rest of the cycle detection process.

@LeaVerou
Copy link
Member Author

LeaVerou commented Mar 7, 2025

In general, I think the way we handle --foo: var(--foo) is suboptimal — there were many better options than IACVT (e.g. resolving to the inherited value). But that ship has sailed at this point, we can no longer change it without an explicit opt in of some kind.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants