- 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