Skip to content

[css-mixins-1] Scoped mixins #13113

@andruud

Description

@andruud

As currently defined, mixin parameters are not evaluated in the context of the element they appear to apply to. Example:

<div class=foo>
  <div class=bar style="--theme:red; font-size:8px"></div>
</div>
@mixin --colorize-tree(--color <color>) {
  &, & * {
    border-color: oklch(from env(--color) calc(l/2) c h);
    accent-color: oklch(from env(--color) calc(l/2) c h);
  }
}

.foo {
  --theme: green;
  @apply --colorize-tree(var(--theme));
}

The above @apply statement would at first appear to colorize the tree green. In reality the var(--theme) gets interpreted against each element it applies to; <div class=bar> gets colorized as red.

The same is true for element-dependent values in general, e.g. em units:

@mixin --pad-tree(--w <length>) {
  &, & * {
    padding: env(--w);
  }
}

.foo {
  font-size: 20px;
  @apply --pad-tree(1em);
}

The above looks like it applies a padding of 1em = 20px to the whole subtree, when in reality it's using the font-size of the elements that are ultimately matched; <div class=bar> gets a padding of 8px. See also this relevant example in the specification.

Short of disallowing computationally dependent values, there is not much we can about this in the current model; env() traverses lexical scopes looking for a matching name, without any knowledge of elements at all.


In the var()-based model for mixin parameters, parameters and locals are based on the same dynamic scoping model proposed (or at least spearheaded) by @LeaVerou (#10954). This means we could potentially perform a "hygienic rewrite" (so called by @tabatkins) of the parameters; the --pad-tree example becomes effectively:

@function --f(--w <length>) {
  result: var(--w);
}

.foo {
  font-size: 20px;
  /* Secret, unobservable custom prop from the @apply call: */
  --pad-tree-param-1: 1em; /* Registered type: <length> */
  &, & * {
    padding: --f(var(--pad-tree-param-1));
  }
}

Here, 1em is interpreted at the @apply-site, and made available in computed-value form to the subtree.

This works only when the mixed-in rules all select (inclusive) descendants of the @apply element, hence the mentions in #12927 about mixins optionally being scoped. Applying a scoped mixin would produce behavior similar to wrapping an @apply call in a @scope rule:

@mixin --push-sibling() {
  & + * {
    margin-left: 200px;
  }
}
div {
  @apply --push-sibling();
}

would expand approximately to:

div {
  @scope (&) {
    & + * { /* Not in scope */
      margin-left: 200px;
    }
  }
}

This scoping would give us a guarantee that any information "captured" on the @apply element due to hygienic rewriting will be available to anything that gets mixed in. The obvious drawback is that elements outside the "apply root" can no longer be matched.

There are several open questions about scoped mixins:

  • Are they scoped by default, and then you opt out? (Or vice versa?)
  • How do you opt-in/out? @mixin unscoped --m() {}?
  • Or do you opt-in/out on the call site? @apply unscoped --m().

cc @mirisuzanne

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions