Learn SCSS mixins by building them. Ten copy-and-run examples covering arguments, defaults, media queries, @content blocks, conditionals, variable arguments, mixin versus extend, and a small mixin library.
The SCSS mixins article covers the most flexible of the three reuse tools: functions return a single value, inheritance shares a fixed set of styles, and a mixin generates a whole block of CSS that can change based on what you pass it. That distinction only becomes real when you compile something and read the output, because the difference between a mixin and an extend is invisible in the source and obvious in the CSS. This workbook works through the whole range: a mixin with no arguments, parameters, defaults and named arguments, a mixin carrying its own media query, passing an entire block in with @content, branching with @if, variable arguments, the choice between a mixin and a placeholder and a function, keeping mixins in a partial, and a small library assembled from the lot. The idea that ties it together arrives in the last example, and it explains what a mixin is actually costing you. You need the compiler for these, so install it once with npm install --save-dev sass and compile with npx sass main.scss main.css.
1. Your first mixin
A mixin is a named block of declarations you define once and drop into any selector that wants them. No arguments needed to start.
// main.scss@mixin card-surface { background: #fff; border: 1px solid #e2e6ef; border-radius: 8px; padding: 24px;}.card { @include card-surface; }.sidebar { @include card-surface; margin-top: 16px; }
/* main.css */.card { background: #fff; border: 1px solid #e2e6ef; border-radius: 8px; padding: 24px;}.sidebar { background: #fff; border: 1px solid #e2e6ef; border-radius: 8px; padding: 24px; margin-top: 16px;}
Four declarations written once and rendered twice. The @include line was replaced in place by the mixin’s contents, which is why .sidebar shows the shared styles first and its own margin-top after, in exactly the order the source reads. Notice what did not appear in the output: the mixin definition itself contributes nothing on its own, so an unused mixin costs you no bytes at all. Also notice that the parentheses are optional when there are no arguments, so @include card-surface; and @include card-surface(); are the same thing. Read the compiled file and one fact should already be uncomfortable, which is that those four declarations now exist twice, and Example 8 is where that bill comes due.
2. Parameters: one mixin, many variations
The reason to reach for a mixin rather than inheritance is that the shared styles can differ each time. Parameters are how.
// main.scss@mixin badge($bg) { display: inline-block; padding: 4px 10px; border-radius: 999px; font-size: 12px; background: $bg; color: #fff;}.badge { @include badge(#6f7580); }.badge--new { @include badge(#1e2331); }
/* main.css */.badge { display: inline-block; padding: 4px 10px; border-radius: 999px; font-size: 12px; background: #6f7580; color: #fff;}.badge--new { display: inline-block; padding: 4px 10px; border-radius: 999px; font-size: 12px; background: #1e2331; color: #fff;}
Two badges identical in every respect but the one that matters. The parameter behaves like a local variable inside the mixin, visible only there, so $bg cannot leak out and collide with anything in the wider stylesheet. This is the test that decides between the tools: if every use would be byte-for-byte identical then a placeholder and @extend will produce smaller CSS, and the moment one value needs to vary, a mixin is the only one of the two that can express it. Name it after what it produces rather than where you first used it, so badge rather than header-pill, because the second name will be a lie within a month.
3. Defaults and named arguments
Parameters can carry defaults, which makes them optional. The caller then overrides only the ones it cares about.
// main.scss@mixin elevation($y: 3px, $blur: 8px, $color: rgba(30, 35, 49, 0.15)) { box-shadow: 0 $y $blur $color;}.panel { @include elevation; } // every default.modal { @include elevation($blur: 24px); } // named: skip straight to the third.tooltip { @include elevation(1px, 2px); } // positional
/* main.css */.panel { box-shadow: 0 3px 8px rgba(30, 35, 49, 0.15);}.modal { box-shadow: 0 3px 24px rgba(30, 35, 49, 0.15);}.tooltip { box-shadow: 0 1px 2px rgba(30, 35, 49, 0.15);}
Three shadows from one mixin and three different calling styles. The named argument in .modal is the one to take away: it changes the blur without restating the offset before it, which positional arguments cannot do, and it documents itself at the call site so a reader knows what 24px means without opening the mixin. The rule of thumb that falls out is to order parameters with the most commonly overridden first, so positional calls stay readable, and to name anything past the second argument. Defaults are also where a mixin earns the description “sensible by default, adjustable when needed”, since .panel got a considered shadow by typing nothing at all.
4. A mixin that carries its own media query
Because a mixin outputs blocks rather than values, it can contain nested rules and at-rules. A responsive type scale is the classic case.
// main.scss@mixin fluid-type($small, $large) { font-size: $small; @media (min-width: 768px) { font-size: $large; }}h1 { @include fluid-type(1.5rem, 2.5rem); }h2 { @include fluid-type(1.25rem, 1.75rem); }
/* main.css */h1 { font-size: 1.5rem;}@media (min-width: 768px) { h1 { font-size: 2.5rem; }}h2 { font-size: 1.25rem;}@media (min-width: 768px) { h2 { font-size: 1.75rem; }}
Each heading got a base size and a media query of its own, and the compiler wrote the selector into the query for you rather than expecting you to restate it. That is the feature: the breakpoint lives in one place, so changing 768 to 900 updates every element using the mixin at once, which is the thing that never happens when breakpoints are copied by hand across a stylesheet. The cost is visible in the output, where the same @media (min-width: 768px) appears twice, once per include. That is fine and standard, since gzip handles the repetition well and modern browsers do not care, but it does mean the technique in the next example is worth knowing before your mixin count grows.
5. Passing a whole block in with @content
A mixin can accept not just values but an entire block of CSS, using @content. This turns a breakpoint mixin into something you can wrap around anything.
// main.scss@use "sass:map";$breakpoints: (small: 480px, medium: 768px, large: 1024px);@mixin respond-to($name) { @media (min-width: map.get($breakpoints, $name)) { @content; // whatever the caller wrapped in braces lands here }}.layout { display: grid; gap: 12px; @include respond-to(medium) { grid-template-columns: repeat(2, 1fr); } @include respond-to(large) { grid-template-columns: repeat(3, 1fr); }}
/* main.css */.layout { display: grid; gap: 12px;}@media (min-width: 768px) { .layout { grid-template-columns: repeat(2, 1fr); }}@media (min-width: 1024px) { .layout { grid-template-columns: repeat(3, 1fr); }}
Two breakpoints applied without either pixel value appearing in the component. @content is what separates a mixin that emits fixed declarations from one that acts as a wrapper, and it is why this pattern beats Example 4’s approach at scale: the mixin no longer needs to know what you want to change, only where the change applies. Naming the breakpoints in a map means respond-to(medium) reads as intent rather than arithmetic, and one edit to that map moves every query in the project. Note the modern spelling, since map-get still works but is deprecated in favour of map.get with @use "sass:map" at the top, and Dart Sass will warn you about the old form.
6. Branching inside a mixin with @if
Mixins can make decisions, producing different output depending on the arguments they receive.
// main.scss@use "sass:color";@mixin tinted($color, $dark: false) { @if $dark { background: color.mix($color, #000, 70%); color: #fff; } @else { background: color.mix($color, #fff, 15%); color: #1e2331; }}.notice { @include tinted(#3f88c5); }.notice--strong { @include tinted(#3f88c5, $dark: true); }
/* main.css */.notice { background: rgb(88.7058823529%, 93%, 96.5882352941%); color: #1e2331;}.notice--strong { background: rgb(17.2941176471%, 37.3333333333%, 54.0784313725%); color: #fff;}
One mixin, two related cases, and only the branch that matched appearing in the output. Passing $dark: true by name is doing real work here, because @include tinted(#3f88c5, true) would compile identically and tell a reader nothing. Two things about the colour functions are worth knowing before you copy this. darken() and lighten() from the older documentation are deprecated, and color.mix or color.scale are the current tools, both of which stay inside the valid range instead of clamping awkwardly at the extremes. And modern Dart Sass emits colours as percentage rgb() rather than hex, which is valid CSS and surprising the first time you see it in a diff.
7. Variable arguments
When a property legitimately takes any number of comma-separated values, ... lets a mixin accept them all and pass them straight through.
// main.scss@mixin transition($props...) { transition: $props;}$hover-speed: (color 0.15s ease-in, background-color 0.15s ease-in);.btn { @include transition(background-color 0.2s ease); }.card { @include transition(transform 0.3s ease, box-shadow 0.3s ease); }.link { @include transition($hover-speed...); } // spread a list back out
/* main.css */.btn { transition: background-color 0.2s ease;}.card { transition: transform 0.3s ease, box-shadow 0.3s ease;}.link { transition: color 0.15s ease-in, background-color 0.15s ease-in;}
One argument list, two arguments, and a stored list expanded back into arguments, all handled by the same mixin. The trailing ... in the definition gathers everything into a list, and the same ... at a call site does the reverse, unpacking a list into individual arguments, which is how you keep a shared timing definition in a variable and still pass it to something expecting separate values. Reach for this only when the underlying CSS property really is variadic, as transition, box-shadowand grid-template-columns are. A mixin that accepts unlimited arguments because you could not decide on a signature is harder to read than three focused mixins, which is the article’s warning about elaborate parameters in practical form.
8. Mixin, placeholder, or function
The three reuse tools produce visibly different CSS. Compiling all of them side by side is the fastest way to internalise which to reach for.
// main.scss// A placeholder, shared by @extend%surface { background: #fff; border-radius: 8px;}.panel-a { @extend %surface; padding: 24px; }.panel-b { @extend %surface; padding: 12px; }// The same styles as a mixin@mixin surface { background: #fff; border-radius: 8px;}.panel-c { @include surface; padding: 24px; }.panel-d { @include surface; padding: 12px; }// A function returns one value, never a block@function stack($steps) { @return $steps * 8px; }.panel-e { padding: stack(3); }
/* main.css */.panel-b, .panel-a { background: #fff; border-radius: 8px;}.panel-a { padding: 24px;}.panel-b { padding: 12px;}.panel-c { background: #fff; border-radius: 8px; padding: 24px;}.panel-d { background: #fff; border-radius: 8px; padding: 12px;}.panel-e { padding: 24px;}
The whole trade-off in one file. @extend produced a single grouped rule, so the shared declarations exist once no matter how many selectors use them, while the mixin wrote them out again for every include. That makes extend the smaller output and mixins the flexible one, since a placeholder cannot take an argument and therefore cannot vary. Two details to carry from the compiled file: extend rewrote the grouped selector as .panel-b, .panel-a, in an order you did not choose and should not depend on, and it also hoisted those styles to where the placeholder was defined rather than where it was used, which can reorder your cascade in ways that surprise you. The function produced no block at all, just the number 24px, which is the line between the tools.
9. Keeping mixins in a partial
Mixins scattered through component files are mixins nobody reuses. Convention puts them in _mixins.scss and loads them by name.
// _mixins.scss@mixin card-surface($padding: 24px) { background: #fff; border: 1px solid #e2e6ef; border-radius: 8px; padding: $padding;}
// main.scss@use 'mixins' as m;.card { @include m.card-surface; }.compact { @include m.card-surface(12px); }
/* main.css */.card { background: #fff; border: 1px solid #e2e6ef; border-radius: 8px; padding: 24px;}.compact { background: #fff; border: 1px solid #e2e6ef; border-radius: 8px; padding: 12px;}
The same output as Example 1, from a stylesheet where the definitions live somewhere else entirely. The namespace is the part worth adopting: @use 'mixins' as m means every call site reads m.card-surface and announces where the block came from, which matters more than it sounds once a project has its own mixins alongside a framework’s. The older @import 'mixins'still works and drops everything into the global scope, but it is deprecated and gives you no such signal. A partial full of mixins also emits nothing by itself, so it belongs at the top of your entry point with the variables, before anything that could use it, which is the dependency ordering the SCSS imports article sets out.
10. A small mixin library, and the model
Put the techniques together and you get a file worth carrying between projects. These four cover most of what a component stylesheet actually repeats.
// _mixins.scss@use "sass:map";$breakpoints: (medium: 768px, large: 1024px);// Hide visually, keep it available to screen readers@mixin visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;}// One line of text, ellipsis on overflow@mixin truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}// A focus ring that only shows for keyboard users@mixin focus-ring($color: #1e2331) { &:focus-visible { outline: 2px solid $color; outline-offset: 2px; }}// Wrap any block in a named breakpoint@mixin respond-to($name) { @media (min-width: map.get($breakpoints, $name)) { @content; }}
// main.scss@use 'mixins' as m;.skip-link { @include m.visually-hidden; }.card__title { font-size: 20px; @include m.truncate;}.btn { background: #1e2331; color: #fff; padding: 12px 20px; @include m.focus-ring;}.layout { display: grid; gap: 12px; @include m.respond-to(medium) { grid-template-columns: repeat(2, 1fr); }}
/* main.css */.skip-link { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;}.card__title { font-size: 20px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}.btn { background: #1e2331; color: #fff; padding: 12px 20px;}.btn:focus-visible { outline: 2px solid #1e2331; outline-offset: 2px;}.layout { display: grid; gap: 12px;}@media (min-width: 768px) { .layout { grid-template-columns: repeat(2, 1fr); }}
Four mixins producing four different shapes of output: a flat run of declarations, declarations merged into an existing rule, a nested pseudo-class rule the & generated, and a media query wrapped around a block that was written at the call site. Nothing in the components repeats, and the awkward details, the clip-path incantation and the :focus-visible selector, are written once where they can be fixed once.
The model underneath is that a mixin is a parameterised block of output, and every design question about one is really a question about three things. What goes in: nothing, positional arguments, defaults you can skip, named arguments that document themselves, a variadic list, or an entire block through @content. What comes out: declarations, nested rules built with &, at-rules like media queries, or a branch chosen by @if. And how many times: once per @include, which is the difference from @extend and the only real cost, paid in duplicated output rather than in complexity. Read a mixin that way and the guidance about keeping them focused stops being taste and becomes arithmetic, because a mixin that takes eight arguments and branches four ways is one that has been asked all three questions at once, and splitting it is usually the answer.
Work through these and you have the article in practice: a mixin defined and included; parameters that make one block serve many cases; defaults and named arguments; media queries travelling inside a mixin; @content for wrapping blocks you write at the call site; @if for related cases in one place; variadic arguments and the spread that reverses them; the compiled difference between a mixin, a placeholder, and a function; a _mixins.scss partial loaded under a namespace; and a small library plus the in, out, how-many-times model. The habit that follows is a compiling one: when you are unsure whether something should be a mixin, write it both ways and read the CSS, because the source files look equally reasonable and the output settles the argument in about ten seconds.
Thanks for reading, Andrei.
[…] Reusable CSS Blocks with SCSS Mixins: 10 Code-Along Examples […]
[…] Reusable CSS Blocks with SCSS Mixins: 10 Code-Along Examples […]