- From: andruud via GitHub <noreply@w3.org>
- Date: Fri, 10 Oct 2025 12:24:08 +0000
- To: public-css-archive@w3.org
andruud has just created a new issue for https://github.com/w3c/csswg-drafts: == [css-mixins-1] A var()-based model for mixin parameters == Introduction ============ The mixins specification currently implements parameters and locals by way of [custom env](https://drafts.csswg.org/css-mixins-1/#env-rule). Custom environment variables could be useful in their own right, but I'm not convinced they're a good fit for mixins, since they split "parameters"[^1] into two silos that need to be explicitly remembered and dealt with by the author at every site of usage. We also have to invent a new concept of lexically ([or dynamically](https://github.com/w3c/csswg-drafts/issues/12677)) _scoped_ environment variables, which is annoying when custom properties already are intrinsically scoped---a fact we used to solve "locals" for custom functions already. Moreover, I don't think the current vision of custom `env()` works well technically, because it is not clear _when_ (custom) `env()`functions would resolve (https://github.com/w3c/csswg-drafts/issues/12676), and there seems to be no _single_ correct answer: - It cannot resolve at computed-value time (like today), because that would prevent it from working in at-rule preludes, like `@media`. - It cannot resolve at soon-after-parsing time[^2], because that doesn't work well for declarations: - We would need to invent a new way of handling invalid `env()`. (E.g. a `guaranteed-invalid` keyword that we sub in.) - It would mean that e.g. `attr(my-env-containing-attribute)` would no longer work; we cannot substitute stuff in DOM attributes early. - Relatedly, "var()-in-var()" functionality (which we introduced along with the work on inline `if()`/custom functions/argument grammars, https://github.com/w3c/csswg-drafts/issues/11500) would not work: `env(var(--myenv))`. Does that mean that `env()` _sometimes_ resolves at soon-after-parsing time[^2], and _sometimes_ at computed-value time? This seems unfortunate, and annoying to have to design around in the future when adding new interactions with IACVT, for example. Additionally, `div { @apply --my-mixin(var(--x)); }` where the mixin parameter is used in e.g. a `@media` prelude will be forever impossible if we stay on the current path. Proposal ======== Instead of mixin parameters existing in a separate namespace, they are placed in a separate ["hypothetical element"](https://drafts.csswg.org/css-mixins-1/#mixin-args:~:text=applied%20to%20a-,%22hypothetical%22%20element,-.%20Either%20way%2C%20this) that exists _between_ any hypothetical elements created by custom functions and the real element. Essentially, every mixed-in declaration acts as if wrapped by a would-be (unobservable) custom function: ```css @mixin --verdant(--c) { color: oklch(0.7 var(--c) 144); background-color: oklch(0.5 var(--c) 144); } div { @apply --verdant(0.2); } ``` Desugars into: ```css @function --f1(--c) { result: oklch(0.7 var(--c) 144); } @function --g1(--c) { result: oklch(0.5 var(--c) 144); } div { /* CSSNestedDeclarations { */ color: --f1(0.2); background-color: --g1(0.2); /* } */ } ``` This means mixin parameters can be referenced with `var()` as normal, and (crucially) at the normal _time_; cases with `attr()` and "var()-in-var()" should work as expected. Each (nested) `@apply` call creates its own "hypothetical element" per declaration, inheriting locals/arguments between them as defined for custom functions. Here is a more complicated example which adds some color to an element, and places arrows pointing to that element by using `::before` and `::after`, while varying the colors based on a single input color: ```css @mixin --squish(--left-color <color>, --right-color: var(--left-color)) { &::before { content: "🡆"; background-color: var(--left-color); } &::after { content: "🡄"; background-color: var(--right-color); } } @mixin --colorized-squish(--color <color>) { background-color: var(--color); border: 2px solid oklch(from var(--color) calc(l - 0.1) c h); @apply --squish(oklch(from var(--color) calc(l - 0.3) c h), oklch(from var(--color) calc(l - 0.2) c h)); } div { @apply --colorized-squish(tomato); } ``` This desugars to the following, splitting up the nested rules into three sections so it's easier to read: ```css div { /* CSSNestedDeclarations { */ background-color: --f1(tomato); /* } */ } /* background-color */ @function --f1(--color <color>) { result: var(--color); } /* ======== */ div { /* CSSNestedDeclarations { */ /* CSSNestedDeclarations { */ &::before { content: --g1(tomato); background-color: --h1(tomato); } /* } */ /* } */ } /* content */ @function --g1(--color <color>) { result: --g2(); } @function --g2() { result: "🡆"; } /* background-color */ @function --h1(--color <color>) { result: --h2(oklch(from var(--color) calc(l - 0.3) c h), oklch(from var(--color) calc(l - 0.2) c h)); } @function --h2(--left-color <color>, --right-color: var(--left-color)) { result: var(--left-color); } /* ======== */ div { /* CSSNestedDeclarations { */ /* CSSNestedDeclarations { */ &::after { content: --i1(tomato); background-color: --j1(tomato); } /* } */ /* } */ } /* content*/ @function --i1(--color <color>) { result: --i2(); } @function --i2() { result: "🡄"; } /* background-color */ @function --j1(--color <color>) { result: --j2(oklch(from var(--color) calc(l - 0.3) c h), oklch(from var(--color) calc(l - 0.2) c h)); } @function --j2(--left-color <color>, --right-color: var(--left-color)) { result: var(--right-color); } ``` Conceptually, each _declaration_ produced by an `@apply` call gets a "private" custom function call stack with a size equivalent to the number of `@apply` calls needed to get there. I'm assuming it's possible to _effectively_ get the above behavior in a somewhat performant way without literally creating a complete function chain for every declaration. How feasible this is to implement has not yet been seriously investigated; my gut feeling says it should be approachable, but we will have to look into this further. --- There was a concern raised by @EricSL about how the following should resolve: ```css @mixin --set-outer(--outer) { @apply --set-inner(env(--outer)); } @mixin --set-inner(--inner) { @apply --override-outer(env(--inner), blue); } @mixin --override-outer(--inner, --outer) { color: env(--inner); } #whatcolorami { @apply --set-outer(red); /* red, blue, or infinite loop? */ } ``` This concern comes back to _when_ `env()` should resolve, and what envs actually bind to. Are they resolved eagerly at the `@apply` site, or are they transported inside other envs and interpreted at the final usage site? It's not clear how well-defined this is by the spec right now (it currently expects lexical _and_ dynamic scoping of envs simultaneously), but in any case, this proposal would desugar that as (replacing `env()` with `var()`): ```css @function --f1(--outer) { result: --f2(var(--outer)); } @function --f2(--inner) { result: --f3(var(--inner), blue); } @function --f3(--inner, --outer) { result: var(--inner); } #whatcolorami { /* CSSNestedDeclarations { */ color: --f1(red); /* Final color: red */ /* } */ } ``` --- The spec currently has an inline issue which explains that we need an analogue to `inherit`, but for parent lexical scopes of `env()`. With this proposal, we can obviously just use `inherit`, and it will resolve to the value of the parent _dynamic_ scope: ```css @mixin --color-or-default(--c: inherit) { color: var(--c); } :root { --c: tomato; } .foo { @apply --color-default(); /* tomato */ } .bar { @apply --color-default(olive); /* olive */ } ``` Locals ====== Instead of using scoped custom environment variables to represent locals, we could use a special at-rule to add local custom properties to the "hypothetical element": ```css @mixin --decorate(--c) { @local --temp: oklch(from var(--c) 0.3 0.2 h); border-color: var(--temp); accent-color: var(--temp); } div { @apply --decorate(olive); } ``` Here, `@local` (name pending) is basically a way to add a custom property to the custom functions "generated" to carry out this mixin: ```css @function --f1(--c) { --temp: oklch(from var(--c) 0.3 0.2 h); result: var(--temp); } @function --g1(--c) { --temp: oklch(from var(--c) 0.3 0.2 h); result: var(--temp); } div { /* CSSNestedDeclarations { */ border-color: --f1(olive); accent-color: --g1(olive); /* } */ } ``` Inner `@apply` calls would be able to see a reference to this `--temp` property by the regular inheritance mechanism of custom functions. Locals "cascade" within the mixin; last seen wins: ```css @mixin --late-green() { @local --temp: red; border-color: var(--temp); accent-color: var(--temp); @local --temp: green; } div { @apply --late-green(); /* green, not red */ } ``` Locals are allowed within conditionals, but are not _scoped_ to their parent rules. (Like how locals work in custom functions.) Block Conditionals ================== Conditional at-rules (e.g. `@media`) are supported within `@function` rules, but we don't yet support arbitrary substitution functions within those preludes: ```css @function --f(--x) { @media (width < var(--x)) { /* Not supported yet */ result: 1; } result: 2; } ``` I suggest we adopt the same behavior for `@mixin` for now, and defer support for var()-in-preludes to a future level. That said, it does appear that we have all the building blocks we need to actually support this soon. With the `@if` proposal in https://github.com/w3c/csswg-drafts/issues/12909, the following would be possible, for example: ```css @mixin --m(--x) { @if media(width < var(--x)) { font-size: 20px; color: green; } } ``` From there, making `@media` work with `var()` is a matter of defining it to behave like `@if`. Limitations =========== @LeaVerou has expressed concern that e.g. `@apply var(--my-mixin-name);` isn't possible. While this proposal would make `@apply --fixed-name(var(--x))` viable (where `--x` comes from the element) , it still doesn't allow dynamic dispatch of the mixin names themselves. I can see the value of this, but I could not figure out how to make this work while also retaining the capability of having arbitrary nested rules within mixins. If we imagine a mixin that _only_ accepts declarations (think of it as a parameterized custom shorthand that expands late), then I believe `@apply var(--my-mixin-name);` should be possible. If we need to discuss this, it should probably be in a separate issue. Summary ======== Proposals: - Drop custom environment variables from css-mixins-1. - Desugar mixins into custom functions. - Defer support for var() in conditional preludes (e.g. @media) until later. cc editors: @mirisuzanne, @tabatkins [^1]: In this case, "parameter" includes regular custom properties, since they also effectively parameterize styles. [^2]: I.e. the time when the parsed tree of rules is traversed and rules are sorted into a more efficient structure for matching. This is also the time (in Blink, at least) when media queries for regular `@media` rules are evaluated. Please view or discuss this issue at https://github.com/w3c/csswg-drafts/issues/12927 using your GitHub account -- Sent via github-notify-ml as configured in https://github.com/w3c/github-notify-ml-config
Received on Friday, 10 October 2025 12:24:08 UTC