[csswg-drafts] [css-mixins-1] Scoped mixins (#13113)

andruud has just created a new issue for https://github.com/w3c/csswg-drafts:

== [css-mixins-1] Scoped mixins ==
As currently defined, mixin parameters are not evaluated in the context of the element they _appear_ to apply to. Example:

```html
<div class=foo>
  <div class=bar style="--theme:red; font-size:8px"></div>
</div>
```

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

```css
@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](https://drafts.csswg.org/css-mixins-1/#example-399711fd) 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](https://github.com/w3c/csswg-drafts/issues/12927), parameters and locals are based on the same dynamic scoping model proposed (or at least spearheaded) by @LeaVerou (https://github.com/w3c/csswg-drafts/issues/10954). This means we _could_ potentially perform a "hygienic rewrite" (so called by @tabatkins) of the parameters; the `--pad-tree` example becomes effectively:

```css
@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 https://github.com/w3c/csswg-drafts/issues/12927 about mixins optionally being _scoped_. Applying a _scoped mixin_ would produce behavior similar to wrapping an `@apply` call in a `@scope` rule:

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

would expand approximately to:

```css
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


Please view or discuss this issue at https://github.com/w3c/csswg-drafts/issues/13113 using your GitHub account


-- 
Sent via github-notify-ml as configured in https://github.com/w3c/github-notify-ml-config

Received on Monday, 17 November 2025 14:37:27 UTC