How to Use Native CSS Mixins Instead of Sass
See how to port Sass mixins to native CSS with @mixin and @apply, handle parameters, and identify features that still require Sass and unsupported browser support.
A native CSS mixin is a named block of declarations. You define it with @mixin --name and insert it into a style rule with @apply --name, which makes it the browser-side counterpart to Sass’s @mixin and @include.
If your stylesheets already use custom properties and native nesting, the few shared mixins in a _mixins.scss partial may be the only reason you still run Sass. This article ports one real mixin to native CSS, shows the two versions side by side, and lists which kinds of mixins won’t make the move.
Key Takeaways
- Native CSS mixins use
@mixin --nameto define a block and@apply --nameto use it. Names are dashed idents, and@applydoes the job of Sass’s@include. - Sass copies a mixin’s declarations into every rule that includes it at compile time. A native mixin is expanded by the browser, so the shipped stylesheet contains one definition.
- MDN reports that no browser supports CSS mixins by default, so they are not Baseline and Sass remains the production choice.
- Mixins that depend on Sass maps,
@eachor@forloops,@if/@elsebranching, or build-time math have no native equivalent.
The Sass Mixin You Already Have
A cover mixin pins an element to all four edges of its positioned ancestor. It’s about the simplest mixin there is, which makes it a good test case. Here it is used twice: once on a backdrop and once on a pseudo-element overlay. (For more background on the Sass side, see this guide to SCSS mixins.)
@mixin cover {
position: absolute;
inset: 0;
}
.dialog-backdrop {
@include cover;
background: rgb(0 0 0 / 0.6);
}
.media-card.is-loading::after {
@include cover;
content: "";
background: rgb(255 255 255 / 0.7);
}
On its own, the mixin definition produces no CSS. Every @include copies the declarations into the rule that calls it:
.dialog-backdrop {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 0.6);
}
.media-card.is-loading::after {
position: absolute;
inset: 0;
content: "";
background: rgb(255 255 255 / 0.7);
}
The more rules include the mixin, the more copies of those two declarations end up in the output.
The Native CSS Mixin Rewrite, Side by Side
Porting a declaration-only Sass mixin to native CSS takes three mechanical steps: add two dashes to the front of the name, turn $ parameters into -- parameters, and replace @include with @apply.
Sass
@mixin cover {
position: absolute;
inset: 0;
}
.dialog-backdrop {
@include cover;
background: rgb(0 0 0 / 0.6);
}
Native CSS
@mixin --cover {
position: absolute;
inset: 0;
}
.dialog-backdrop {
@apply --cover;
background: rgb(0 0 0 / 0.6);
}
@apply takes the place of @include, and the mixin name has to be a dashed ident. Because the browser does the expansion, the stylesheet you ship holds a single definition of --cover. The body is just declarations, with no @result wrapper, because the CSSWG resolved to drop @result from mixins. The draft itself warns that mixins are far less settled than custom functions, so the syntax may still shift.
Browser Support for CSS Mixins
Native CSS mixins can’t be used in production. MDN’s guide to custom functions and mixins reports that no browser supports mixins by default, which means the feature isn’t Baseline. Chromium is ahead of the other engines: it has filed an Intent to Prototype for CSS mixins, and that work is experimental.
Native CSS mixins don’t work as a progressive enhancement either. CSS error handling throws away constructs the parser doesn’t understand, so a browser without support skips @apply, and an element that gets position and inset only from the mixin ends up with neither. Keep the Sass version for production, and try native mixins in a plain .css file where the Sass compiler never sees them.
Porting Mixin Parameters
Sass mixin parameters that pass simple values port cleanly to native CSS mixins. Mixins that compute values, branch, or loop at build time don’t. Here is cover with an inset argument:
// Sass
@mixin cover($inset: 0) {
position: absolute;
inset: $inset;
}
.frame::before { @include cover(0.5rem); content: ""; }
/* Native */
@mixin --cover(--inset) {
position: absolute;
inset: var(--inset);
}
.frame::before { @apply --cover(0.5rem); content: ""; }
Each parameter becomes a private property inside the mixin body, and you read it with var(). Other styles on the page can’t see it. var() fallbacks work in the body just as they do in any other declaration. The CSS Custom Functions and Mixins draft also defines its own syntax for typing parameters and giving them defaults. Check the draft for the current form, and don’t assume a fallback behaves exactly like a default. The First Public Working Draft of 15 May 2025 needed parentheses even on a mixin with no parameters, and later drafts let you leave them off. Older prototype builds required @mixin --a() and @apply --a();, so you’ll come across both forms.
These Sass mixin features have no native CSS equivalent:
@eachand@forloops@if/@elsebranching and@error- maps and
map.getfrom thesass:mapmodule - build-time math such as
math.divor unit conversion
The breakpoint mixin shows where the line falls:
@use "sass:map";
$breakpoints: (sm: 40rem, md: 64rem);
@mixin bp($name) {
@if not map.has-key($breakpoints, $name) {
@error "Unknown breakpoint: #{$name}";
}
@media (min-width: map.get($breakpoints, $name)) {
@content;
}
}
The spec defines @contents as the counterpart to Sass’s @content, so in principle a native mixin can take a block from the caller and wrap it in @media. The parts that don’t port are the map lookup, the @error branch, and the variable width inside the media query.
What Do You Gain From Native CSS Mixins?
Native CSS mixins offer two gains over Sass mixins: no build step, and arguments resolved in the browser instead of at compile time. First, the browser reads @mixin and @apply directly, so the CSS you write is the CSS you ship.
The second gain is subtler. Sass can output var() references too, so a Sass mixin can emit declarations that respond to custom properties at runtime. What Sass can’t do is change an argument after compilation: cover(0.5rem) becomes inset: 0.5rem once, during the build. Native mixins are resolved in the browser, and the spec includes handling for arguments whose values depend on the element the mixin is applied to. That behavior belongs to the spec, not to any shipped engine. Custom functions, a sibling feature from the same module defined with @function, return a single value, while mixins return a block of declarations.
When Should You Keep Sass?
Declaration-only mixins such as cover, centering and visually-hidden port cleanly. Mixins that generate selectors, loop over lists, or compute values at build time don’t.
| Mixin | Ports cleanly? | What changes | Why not |
|---|---|---|---|
| Cover / inset | Yes | -- name, @apply, var(--inset) | n/a |
| Visually-hidden | Yes | -- name, @apply | n/a |
| Centering | Yes | -- name, @apply | n/a |
| Breakpoints | No | Block wrapping via @contents in principle | Map lookup, @error, variable media width |
Keep using Sass @mixin and @include for loops that generate utility classes, tokens driven by maps, and breakpoint helpers. Keep using it for everything in production until mixins ship by default in browsers.
Conclusion
Swapping a declaration-only mixin like cover to native syntax is quick and mostly mechanical. Deciding when to switch depends on browser support, and that support doesn’t exist yet. A practical next step: sort your mixins into two groups, declaration-only mixins and ones that rely on Sass logic. Rewrite the first group in a separate .css file and try it in an experimental Chromium build. Leave the Sass versions in place until MDN lists default support.
FAQs
How do I test native CSS mixins in Chrome?
Chromium's mixins prototype sits behind the CSSMixins feature flag. To try it, launch Chrome Canary from the command line with the --enable-features=CSSMixins argument. The implementation is incomplete and changes as the CSSWG resolves spec issues, so some examples may fail or behave differently from the draft. Use it only for experiments and spec feedback, never for production styles.
Why can't I replace a Sass breakpoint variable with var() in a media query?
The var() function works only inside property values, and media query conditions are not property values. Custom properties are resolved for each element through the cascade, while a media query is evaluated against the viewport or device rather than against any element. So a condition like min-width: var(--md) is invalid, and a breakpoint width stored in a variable cannot move from Sass into a native media query.
Can a native CSS mixin include nested selectors or media queries?
Yes, in the spec. The CSS Custom Functions and Mixins draft allows nested style rules, such as an ampersand ::after block, and conditional rules such as @media inside a mixin body, much like a Sass mixin can emit them. No browser ships mixins by default, so this describes the spec's intended behavior rather than anything that runs in a stable engine.
Is postcss-mixins the same as native CSS mixins?
No. The postcss-mixins plugin defines a mixin with @define-mixin, calls it with @mixin, and uses dollar-sign parameters that get expanded at build time. Native CSS uses @mixin to define a mixin and @apply to call it, and the browser expands it. The @mixin keyword means opposite things in the two systems, so moving from postcss-mixins to native syntax means rewriting both definitions and call sites.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k