Native CSS Mixins: Potential, Challenges, and a Look Ahead

css development

Anticipate the arrival of native CSS mixins, a powerful feature poised to enhance stylesheet modularity and reduce reliance on preprocessors. This article explores their functionality, proposed syntax, challenges with parameter handling and scope, and the exciting possibilities for future web development.

I've gathered my thoughts on the concept of native CSS mixins and believe it's time to share them. While native CSS mixins don't exist yet, there's significant progress: Miriam Suzanne of the CSS Working Group has confirmed an agreement to move forward with them.

There's a specification in development, but it currently focuses on @function, which is already implemented. Functions are somewhat similar to mixins but return a single value rather than a block of styles. The core idea for native mixins stems from Sass's @mixin feature.

At CodePen, we heavily rely on Sass (SCSS), with 328 @mixin definitions in our codebase, underscoring their utility.

Here’s a practical, albeit basic, example of a Sass mixin:

@mixin cover {
  position: absolute;
  inset: 0;
}

In Sass, this mixin definition doesn't compile directly into CSS. It must be used, or "included," elsewhere in the stylesheet:

.modal-overlay {
  @include cover;
}

.card .disabled {
  &::before {
    @include cover;
    background: lch(0% 0 0 / 0.8);
  }
}

As demonstrated, the cover mixin is used twice. Upon compilation, Sass will inject the mixin's contents into both locations, resulting in duplicate CSS:

.modal-overlay {
  position: absolute;
  inset: 0;
}

.card .disabled {
  &::before {
    position: absolute;
    inset: 0;
    background: lch(0% 0 0 / 0.8);
  }
}

Sass mixins can be quite sophisticated, yet their principles remain straightforward:

  • Mixins support nesting and function within nested code. They can even incorporate nested content passed to them.
  • Mixins can call other mixins.
  • Mixins can accept parameters, much like functions, allowing for dynamic calculation and usage of those values in the output.

I anticipate and hope that native CSS mixins will support all these capabilities. Based on current discussions (which are subject to change), the primary difference in the native version appears to be the usage syntax:

@mixin --cover {
  position: absolute;
  inset: 0;
}

.modal-overlay {
  @apply --cover;
}

The choice of @apply instead of @include is likely due to Sass already using @include, which could create conflicts when preprocessors transform Sass into CSS.

Is There Sufficient Justification for Browser Adoption?

The W3C CSS Working Group has already approved the concept, suggesting they recognize the inherent value. But what exactly are the compelling reasons for native CSS to include this feature?

  1. Eliminating Preprocessor Reliance: This is a significant factor. While not sufficient on its own for the working group, it's a "paved cowpath" for many developers.
  2. Reducing Duplicate Code: Preprocessor output often contains substantial duplicate code, leading to larger CSS files. Although compression technologies like gzip and Brotli mitigate this, smaller files are generally preferable.
  3. Integration with Custom Properties: This is a critical advantage. Mixin parameters could leverage custom properties, which can dynamically change and re-evaluate styles. This allows mixins to become powerful expressions of style based on minor custom property adjustments. Furthermore, custom properties cascade, meaning mixins could behave differently across various DOM contexts—a capability impossible with preprocessors.
  4. Superior API: Native mixins offer a more intuitive API compared to current workarounds like faking functionality with @container style().

I often wonder what other factors swayed the working group towards this decision.

Parameter Handling Seems Tricky

Passing arguments to a mixin is crucial for their utility. Consider this example:

@mixin --setColors(--color) {
  color: var(--color);
  background-color: oklch(from var(--color) calc(l - 40%) c h / 0.9);
}

However, parameters introduce complexities. What happens if setColors() is called without any parameters?

.card {
  @apply --setColors();
  /* ??? */
}

It's possible that --color might be set at the current cascade level, allowing the mixin to access it and still output styles. If --color is defined at the same cascade level and a parameter is passed, which value takes precedence? How does !important factor into this?

What about typed parameters and default values? While feasible, the syntax could become verbose for CSS. Would it look something like this?

@mixin --setColors(
  --color type(color): red
) {
  color: var(--color);
  background-color: oklch(from var(--color) calc(l - 40%) c h / 0.9);
}

The exact syntax limitations are unclear. Perhaps default values aren't necessary if var() already supports fallbacks.

Opening Up a World of Third-Party CSS Usage

Native mixins could revolutionize how we consume and integrate third-party CSS components. Imagine highly customizable CSS carousels abstracted into a @mixin.

In the jQuery era, a carousel might have been integrated via pseudo-code like:

// <script src="/plugins/owl-carousel.js"></script>
$(".owl-carousel").owlCarousel({
  gap: 10,
  navArrows: true,
  navDots: true
});

This evolved into JavaScript components:

import SlickCarousel from "slickcarousel";

<SlickCarousel gap="10" navArrows={true} navDots={true} />

With native mixins, we might see something akin to:

@import "/node_modules/radcarousel/carousel.css";

.carousel {
  @apply --radCarousel(
    --gap: 10px,
    --navArrows: true,
    --navDots: true
  );
}

Similar to the jQuery version, this would involve DIY HTML, effectively offering server-side rendering (SSR) benefits without extra effort.

What About "Private" Variables?

I recall Miriam Suzanne discussing this issue, likely at CSS Day. Consider this scenario:

@mixin --my-thing {
  --space: 1rem;
  gap: var(--space);
  margin: var(--space);
}

.card {
  @apply --my-thing;
  padding: var(--space); /* defined or not? */
}

The question is whether the --space custom property "leaks out" when the mixin is applied, making it accessible for use outside the mixin. There are three possibilities: 1) it leaks, 2) it doesn't, or 3) explicit syntax is required to control it.

It could be useful for variables to "leak" (be returned) by default, with an option to prevent this. Perhaps a syntax like this:

@mixin --my-thing {
  @private {
    --space: 1rem;
  }
  gap: var(--space);
  margin: var(--space);
}

This approach is not unappealing. Miriam's post also suggests being more explicit about what is returned, perhaps using an @output block or privatizing custom properties with a !private flag.

What About Source Order?

Consider the impact of source order:

@mixin --set-vars {
  --papaBear: 30px;
  --mamaBear: 20px;
  --babyBear: 10px;
}

.card {
  --papaBear: 50px;
  @apply --set-vars;
  margin: var(--papaBear);
}

What margin value would be applied here? 50px because it's set directly, or 30px because the mixin overrides it? What if the first two lines within .card were reversed? Will source order be the definitive factor?

Are Custom Idents Required?

All examples consistently use the --my-mixin naming convention with double-dashes, mirroring custom properties. This is known as a "custom ident" and is required for custom functions, which share the same specification. It's highly probable it will be required for mixins too.

/* 🚫 Invalid */
@mixin doWork {}

/* ✅ Valid */
@mixin --doWork {}

Is this the future for all custom-named elements in CSS? It's reportedly required for anchor names but not container names, leading to inconsistency. While consistency is desirable, backwards compatibility is often prioritized.

It would be clearer if custom idents were required for @keyframes, for instance. In the following code, is it immediately obvious which word is user-defined and which is language syntax?

.leaving {
  animation: slide 0.2s forwards;
}

Here, slide is the user-defined name, requiring a search for its definition:

@keyframes slide {
  to {
    translate: -200px 0;
  }
}

It would be significantly clearer if the syntax were:

.leaving {
  animation: --slide 0.2s forwards;
}

@keyframes --slide {
  to {
    translate: -200px 0;
  }
}

There's nothing preventing us from adopting this convention, or even going further with an emoji-based naming structure.

Calling Multiple Mixins

How would applying multiple mixins work? Would it be:

@apply --mixin-one, --mixin-two;

Perhaps space-separated?

@apply --mixin-one --mixin-two;

Or is that considered awkward, necessitating individual @apply statements?

@apply --mixin-one;
@apply --mixin-two;

Does the specific syntax truly matter, or is functionality the priority?

Functions + Mixins

It seems logical that a mixin could call a function:

@mixin --box {
  gap: --get-spacing(2);
  margin-trim: block;
  > * {
    padding: --get-spacing(4);
  }
}

But would the reverse—a function calling a mixin—be forbidden?

@function --get-spacing(--size) {
  @apply get-vars(); /* ??? */
  result: if(
    style(--some-other-var: xxx): 3rem;
    style(--size: 2): 1rem;
    style(--size: 4): 2rem;
    else: 0.5rem;
  )
}

Or would this be acceptable?

Infinite Loops

Could this introduce infinite loop problems with calculated styles? While I don't know if this is a definite issue, it's a mind-bender.

@mixin --foo(--val) {
  --val: 2;
}

.parent {
  --val: 1;
  .thing {
    @apply --foo(--val);
    --val: if(
      style(--val: 1): 2;
      else: 1;
    );
  }
}

When evaluating .thing, --val inherits 1. The mixin then applies, changing it to 2. Then it's reset to 1 by the if statement, which, if --val is 1, should re-evaluate to 2. This cycle raises questions about how the cascade and application order would be resolved.

Unmixing

Miriam Suzanne provocatively asked, "Can you un-mix a mixin?" This is an excellent question and worth serious consideration. If an elegant solution for "un-mixing" emerges, it would significantly enhance the power of native mixins, providing a capability beyond what any preprocessor can offer. The idea of an @unapply directive, at first thought, doesn't seem unwelcome.

What are your thoughts on native mixins? Are you excited, opposed, or concerned?