Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Blog post on un-mixing a mixin #614

Merged
merged 10 commits into from
Jun 11, 2024
375 changes: 375 additions & 0 deletions content/blog/2024/removing-mixins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,375 @@
---
title: Can you un-mix a mixin?
sub: Rethinking the CSS mixin proposal after CSS Day
author: miriam
date: 2024-06-11
tags:
- Article
- Sass
- CSS
- CSSWG
- Mixins & Functions
---

I've been thinking about
[CSS-native mixins](https://css.oddbird.net/sasslike/mixins-functions/).
How do we create re-usable blocks of styling
that can be 'mixed in' to various selectors,
based on arbitrary conditions?

## Mixin substitution with `@apply`

I made [a proposal](https://css.oddbird.net/sasslike/mixins-functions/)
last year, and it was
[adopted by the CSS Working Group](https://github.com/w3c/csswg-drafts/issues/9350#issuecomment-1939628591)
for further exploration and specification.
That proposal is similar to
mixins in Sass and other pre-processors,
mirisuzanne marked this conversation as resolved.
Show resolved Hide resolved
and builds on CSS Nesting:

```css
/* define it once */
@mixin --visually-hidden {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}

/* use in various places */
[visually-hidden] {
@apply --visually-hidden;
}

[hidden-when=small] {
@container (inline-size < 20ch) {
@apply --visually-hidden;
}
}
```

At parse time,
the browser can
(with some minor caveats)
substitute each mixin call
with a nested block of declarations:

```css
[visually-hidden] {
& {
clip: rect(0 0 0 0);
clip-path: inset(50%);
/* … */
}
}

[hidden-when=small] {
@container (inline-size < 20ch) {
& {
clip: rect(0 0 0 0);
clip-path: inset(50%);
/* … */
}
}
}
```

This is a straight-forward approach,
that should be possible to implement.
As authors
we can build on that
by including selectors and conditions
inside the mixin code,
or by passing in arguments.
It's a useful feature,
but it has some limitations.

## Style queries and 'layered toggles'

We don't have CSS-native mixins yet,
but we do have style queries (in Chromium)
which can be used for mixin-like behavior:

```css
/* define the mixin */
@container style(--fancy-em) {
em {
background: linear-gradient(
to bottom right,
var(--fancy-em)
);
color: white;

@supports (background-clip: text) or (-webkit-background-clip: text) {
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: bold;
}
}
}

/* apply the mixin by giving --fancy-em a value */
p {
--fancy-em: mediumvioletred, teal;
}
```

(You can see this [style query demo](https://codepen.io/miriamsuzanne/pen/qBGXMPg?editors=1100) working in a Chromium browser.)

While that may be useful once supported everywhere,
it has an even stronger 'nesting' requirement:
container queries cannot apply styles
to the container itself.
The example above works
because we query the parent paragraph
to apply styles on nested `em` elements.

[Roma Komarov](https://kizu.dev/)
has developed another mixin-like syntax
that works today in all major browsers,
using '[cyclic toggles](https://kizu.dev/cyclic-toggles/)'
and `revert-layer` to create what he calls
[Layered Toggles](https://kizu.dev/layered-toggles/#future-of-mixins):

```css
@layer defaults {
/* any defaults need to be defined in lower layers */
p { width: 80%; }
}

/* Define mixins in a higher layer */
@layer mixins {
*:not(:focus):not(:active) {
--hidden: var(--hidden--off);
--hidden--off: var(--hidden,);
--hidden--on: var(--hidden,);

clip:
var(--hidden--off, revert-layer)
var(--hidden--on, rect(0 0 0 0));
clip-path:
var(--hidden--off, revert-layer)
var(--hidden--on, inset(50%));
height:
var(--hidden--off, revert-layer)
var(--hidden--on, 1px);
/* etc… */
}
}

/* apply the mixin by overriding the custom property */
[hidden-when=small] {
@container (inline-size < 40ch) {
--hidden: var(--hidden--on);
}
}
```

It's not the most elegant solution,
but it works --
and can apply style changes directly, today,
without any nesting.

## Custom properties _cascade_

'The Cascade' in CSS
is an algorithm to resolve conflicts.
Every property (including custom properties)
on a given element
can only have a _single value_.
If the same property is declared twice,
only one of those declarations will apply --
the one with higher _cascade priority_
(specificity, layers, source order, etc).

What stood out to me
during Roma's
[talk at CSS Day](https://cssday.nl/2024/speakers#roma)
was the fact that both these pseudo-mixin solutions
use custom properties to apply the mixin.
As a result,
the mixing-in declaration --
the code that applies or doesn't apply the mixin
-- _cascades_.

That leads to an interesting feature:
a mixin can be applied in one place,
and removed somewhere else.
Once 'turned off',
it's as though the mixin was never applied at all.
The properties simply revert to their
un-mixed-in state:

{% import 'embed.macros.njk' as embed %}

{{ embed.codepen(
id='dyEzQPz',
title='Un-mixing a mixin',
user='miriamsuzanne',
tab='css,result'
) }}


## It's hard to un-mix a previously mixed-in mixin once mixed
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great heading


We don't get that same behavior
with the `@apply` rule as currently defined.
If we apply the same mixin twice
with different values,
both rules will be replaced by the internals of the mixin.

```css
@mixin --card(--color) {
background: var(--color);
border: thick solid color-mix(in oklch, var(--color), black);
}

p { @apply --card(pink); }
p:last-child { @apply --card(powderblue); }
```

The result after substitution will be:

```css
p {
background: pink;
border: thick solid color-mix(in oklch, pink, black);
}
p:last-child {
background: powderblue;
border: thick solid color-mix(in oklch, powderblue, black);
}
```

In some cases,
that will be fine.
The properties inside the mixin
will continue to cascade.
Each declaration of `background` overrides the previous,
and the same with `border`.
In the end, we get the expected result.

But we can't 'remove' the mixin,
or any of those property definitions,
we can only override them with new values.
That can be a real problem.

Let's go back to our `visually-hidden` example.
We should really clarify that
we don't want it applied when an element has focus.
With the custom property,
we can override a single property
wherever necessary:

```css
[visually-hidden] {
--hidden: var(--hidden--on);
}

[hidden-when=small] {
@container (inline-size < 20ch) {
--hidden: var(--hidden--on);
}
}

:focus,
:active {
--hidden: var(--hidden--off);
}
```

But with `@apply` we either have to
plan ahead for all conditions
_before we apply the mixin_:

```css
[visually-hidden]:not(:focus):not(:active) {
@apply --visually-hidden;
}
```

Or we need to carefully revert
every property of the mixin.
But… revert _to what value_?
Maybe we can use `revert-layer`
and some clever layering?
We could even build the off-switch into our mixin:
mirisuzanne marked this conversation as resolved.
Show resolved Hide resolved

```css
/* when '--off' is undefined we get the output */
@mixin --visually-hidden(--off) {
clip: var(--off, rect(0 0 0 0));
clip-path: var(--off, inset(50%));
height: var(--off, 1px);
overflow: var(--off, hidden);
position: var(--off, absolute);
white-space: var(--off, nowrap);
width: var(--off, 1px);
}

@layer base {
[visually-hidden] {
@apply --visually-hidden;
}
}

/* put our overrides in a higher layer to revert from */
@layer overrides {
:focus,
:active {
/* set --off to revert-layer */
@apply --visually-hidden(revert-layer);
}
}
```

That provides an off-switch,
mirisuzanne marked this conversation as resolved.
Show resolved Hide resolved
but it requires some careful planning ahead,
and the layering requirement seems fragile.

## Should mixin calls cascade?

This isn't a new or theoretical issue.
After years of using Sass mixins,
it's something I've encountered many times.
In most situations
it's possible to work around the issue,
but sometimes it becomes quite complicated
to get all the logic right in one place.

The cascade is useful for these situations.
When we're defining new CSS features
we often ask _should these behaviors cascade together_,
are they intertwined?
If so, they belong in the same property.

It seems clear to me that it would be
useful (at least sometimes)
for the mixin-application syntax to cascade.
But before we make any big changes
we also need to ask:

- Are there place we _don't want_ that behavior?
mirisuzanne marked this conversation as resolved.
Show resolved Hide resolved
Places we want to call a mixin twice with different arguments,
and have both apply?
I haven't thought of good examples,
but they might exist?
- Can we design a cascading mixin-application syntax?
We intentionally avoided cascading mixin _definitions_ --
one mixin name can't refer to different things in different places.
Would cascading `@apply` rules have similar issues?

I don't think we want something like
the JavaScript `removeEventListener()` function,
which requires a careful matching of arguments.
I'd like to avoid any `@un-apply`-style rules.
That has always seemed fragile to me,
and I'd rather use the cascade if we can.

What do you think?
Should mixin-calls cascade?
mirisuzanne marked this conversation as resolved.
Show resolved Hide resolved
Are there use-cases for both behaviors?
mirisuzanne marked this conversation as resolved.
Show resolved Hide resolved