Skip to content

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

@andruud

Description

@andruud

Introduction

The mixins specification currently implements parameters and locals by way of custom env. 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) 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 (#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 time2, 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, [css-values] Short-circuit if() evaluation #11500) would not work: env(var(--myenv)).

Does that mean that env() sometimes resolves at soon-after-parsing time2, 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" 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:

@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:

@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:

@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:

div {
  /* CSSNestedDeclarations { */
    background-color: --f1(tomato);
  /* } */
}

/* background-color */
@function --f1(--color <color>) {
  result: var(--color);
}

/* ======== */

div {
  &::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 {
  &::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:

  @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()):

  @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:

@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":

@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:

@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:

@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:

@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 #12909, the following would be possible, for example:

@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

EDIT: Removed some CSSNestedDeclarations comments that were not helpful.

Footnotes

  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. 2

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    Friday Afternoon

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions