[csswg-drafts] [css-mixins-1] A var()-based model for mixin parameters (#12927)

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