Mixins, Functions, @extend and Control Flow: Removing Repetition with @if, @each and @for
Use a mixin when you need parameters, media queries or generated selectors, a placeholder with @extend for rules that really are the same, and @function to compute values; generate repeated rules from data with maps and @each. We add a breakpoint mixin and stock badges to Bookstore, and measure why slash division produces invalid CSS like 12px/2, how @extend breaks inside media queries and what making everything a mixin costs before and after gzip.
Version note: The examples in this post were run with Dart Sass 1.108.2, Angular 22.2 (TypeScript 6.0; the Sass 1.104.1 bundled with the Angular CLI) and the Chromium build of Playwright 1.65.
Guess First
An old stylesheet written for Bookstore, the online bookstore used as the shared example across this site's series, calculates the price tag's padding like this:
$gap: 12px;
.price-tag {
padding: 12px / 2;
padding-block: $gap / 2;
}
Both lines look like "half of 12". What do padding and padding-block end up as in the compiled CSS? Does anything get printed in the terminal during compilation? The answer shows why Sass has given the slash two meanings for years, and why that changes in Dart Sass 2.0.
In the previous post we covered variables, nesting and interpolation. This post moves on to the tools Sass offers for removing repetition: mixins, functions, maps and loops. At the end we add a breakpoint mixin, loop-generated spacing utilities, and stock badges that pick their text color from their background to Bookstore's Angular client (bookstore-web).
@mixin and @include: Arguments, Default Values and @content
A mixin is a named block of declarations that can take parameters. Its content is written out again wherever it is @included. Media queries were the most repeated thing on Bookstore's catalog page, so I turned them into a mixin:
@use "sass:map";
$breakpoints: (
"sm": 36rem,
"md": 48rem,
"lg": 64rem,
);
@mixin from($name) {
@if not map.has-key($breakpoints, $name) {
@error "Unknown breakpoint '#{$name}'. Expected one of: #{map.keys($breakpoints)}";
}
@media (width >= map.get($breakpoints, $name)) {
@content;
}
}
// arguments with default values
@mixin focus-ring($color: var(--color-brand), $offset: 2px) {
&:focus-visible {
outline: 2px solid $color;
outline-offset: $offset;
}
}
.book-list {
display: grid;
gap: 16px;
@include from("md") {
grid-template-columns: repeat(2, 1fr);
}
@include from("lg") {
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
}
.book-card__link {
@include focus-ring;
}
.cart-button {
@include focus-ring($offset: 4px);
}
.book-list {
display: grid;
gap: 16px;
}
@media (width >= 48rem) {
.book-list {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width >= 64rem) {
.book-list {
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
}
.book-card__link:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.cart-button:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 4px;
}
@content places the block the caller wrote in curly braces wherever you want inside the mixin. from() sets up the media query, and the caller decides what goes inside it. Because the media query is written inside the rule, Sass lifts it out and moves the selector inside it; the output has a separate @media block per breakpoint.
focus-ring shows two things. First, arguments with default values: @include focus-ring; can be called without parentheses, and with a named argument such as $offset: 4px you pass only the value you want to change. Second, using & inside a mixin: & becomes the selector of the rule the mixin is included in. Making the default a custom property (var(--color-brand)) is deliberate too: Sass writes the value as is, so the outline color follows the theme.
The breakpoint map has one more benefit: the error message. Since native CSS cannot use var() in a media query, breakpoint values are typed into every file by hand, and nobody notices when someone writes 46em instead of 48em. Here an invalid name breaks the build.
@function and sass:math
A function returns a value; a mixin produces CSS. In Bookstore the measurements from the designer are in pixels; we want to send rem to the browser so the user's font size setting is reflected in the layout:
@use "sass:math";
$root-font-size: 16px;
@function rem($px) {
@if math.unit($px) != "px" {
@error "rem() expects px, got #{$px}";
}
@return math.div($px, $root-font-size) * 1rem;
}
.book-card__title {
font-size: rem(18px);
margin-block-end: rem(6px);
}
$gap: 12px;
.price-tag {
// two literal numbers: Sass keeps the slash
padding: 12px / 2;
// a variable on one side: division, with a deprecation warning
padding-block: $gap / 2;
// the modern way
margin: math.div(12px, 2);
// inside calc() a slash is always division
inset-inline-end: calc(100% / 3);
// a real CSS slash: aspect-ratio and grid-area
aspect-ratio: 2 / 3;
}
npx sass 03/functions.scss
DEPRECATION WARNING [slash-div]: Using / for division outside of calc() is deprecated and will be removed in Dart Sass 2.0.0.
Recommendation: math.div($gap, 2) or calc($gap / 2)
More info and automated migrator: https://sass-lang.com/d/slash-div
╷
23 │ padding-block: $gap / 2;
│ ^^^^^^^^
╵
03/functions.scss 23:20 root stylesheet
.book-card__title {
font-size: 1.125rem;
margin-block-end: 0.375rem;
}
.price-tag {
padding: 12px/2;
padding-block: 6px;
margin: 6px;
inset-inline-end: 33.3333333333%;
aspect-ratio: 2/3;
}
Here is the answer to the guess. padding: 12px / 2 reaches the compiled CSS as 12px/2; that is an invalid value for padding and the browser drops the declaration. padding-block: $gap / 2 becomes 6px, but Sass prints a deprecation warning. The reason is CSS itself: in CSS the slash is a separator, not division. In font: 16px/1.5, grid-area: 1 / 2 or aspect-ratio: 2 / 3 it separates two values. For years Sass used a heuristic: "if both sides are literal numbers it is a separator; if there is a variable or function result, it is division". That heuristic causes surprises like the one above and clashes with new CSS syntax. In Dart Sass 2.0, / will be a separator everywhere, as in CSS; division will be left to math.div() or calc().
The warning suggests two fixes, and they do different things. math.div($gap, 2) divides at build time and writes 6px. calc($gap / 2) is simplified by Dart Sass and also comes out as 6px, but if one side is something unknown at build time, like 100%, it leaves the division to the browser. That simplification is also why calc(100% / 3) became 33.3333333333%: both sides are known values.
The @error in the rem() function matters too. If someone writes rem(1.5rem), the build stops with Error: "rem() expects px, got 1.5rem". Checking types and units inside functions keeps a wrong unit from silently turning into a wrong number.
Generating Rules with Maps, @each, @for and @if
Sass's strongest feature that still has no native CSS equivalent is generating rules from data. Bookstore's spacing scale has five steps; instead of writing the margin utilities for each one by hand, I used two maps and a nested @each:
@use "sass:color";
@use "sass:map";
$spacers: (
0: 0,
1: 4px,
2: 8px,
3: 16px,
4: 24px,
);
$sides: (
"": null,
"t": "block-start",
"b": "block-end",
"x": "inline",
"y": "block",
);
// .m-0 … .my-4: 5 sizes × 5 sides
@each $suffix, $side in $sides {
@each $key, $size in $spacers {
.m#{$suffix}-#{$key} {
@if $side {
margin-#{$side}: $size;
} @else {
margin: $size;
}
}
}
}
// .cols-1 … .cols-4
@for $i from 1 through 4 {
.cols-#{$i} {
grid-template-columns: repeat($i, minmax(0, 1fr));
}
}
$stock-states: (
"in-stock": #15803d,
"low": #fbbf24,
"preorder": #1d4ed8,
"out": #e7e5e4,
);
// text color follows the background: dark text on light badges
@each $state, $bg in $stock-states {
.stock-badge--#{$state} {
background-color: $bg;
@if color.channel($bg, "lightness", $space: oklch) > 70% {
color: #1c1917;
} @else {
color: #ffffff;
}
}
}
The output has 33 rules: 25 margin utilities, 4 grid column classes and 4 badges. A few examples:
.m-0 {
margin: 0;
}
...
.mx-3 {
margin-inline: 16px;
}
...
.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
...
.stock-badge--low {
background-color: #fbbf24;
color: #1c1917;
}
...
.stock-badge--preorder {
background-color: #1d4ed8;
color: #ffffff;
}
When a map key is an empty string ("": null), interpolation adds nothing; that is why .m-0 does not need a separate loop. null counting as false in @if $side is the second half of the same trick. In Sass only false and null are falsy; 0 and the empty string are truthy.
The @if in the badges looks at the lightness channel of the background color in OKLCH. Yellow came out at about 84% lightness and green at 53%, so the yellow badge got dark text and the green one white. contrast-color(), which makes the same decision in the browser, was covered in the color post of the CSS3 series but is not in every browser yet; as long as the colors are known at build time, Sass makes this decision today for every browser. It does not replace an accessibility check; the actual contrast ratio still has to be measured with the tools covered in the Accessibility (a11y) series.
In Bookstore: the Breakpoint Mixin and Stock Badges
In bookstore-web, the breakpoint map and the from() mixin moved to src/styles/_breakpoints.scss, and the spacing utilities to src/styles/_utilities.scss. Files starting with an underscore are partials: they are not compiled on their own, only loaded from another file. The catalog component uses the mixin like this:
@use "../styles/breakpoints" as bp;
.site-header {
padding: var(--space) calc(var(--space) * 1.5);
border-bottom: 1px solid var(--color-border);
}
.book-list {
display: grid;
gap: var(--space);
padding: calc(var(--space) * 1.5);
@include bp.from("sm") {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include bp.from("lg") {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
The @use line loads the file and gives us access to everything in it under the bp. prefix. Relative paths and namespaces are the subject of the next post, where we will also get rid of these ../styles/ paths. The stock badges live in BookCard's stylesheet and are generated by the @each above (Bookstore has no pre-order state yet, so there are three colors). I measured the catalog grid at three widths and the badge colors:
400px .book-list grid-template-columns=352px
700px .book-list grid-template-columns=318px 318px
1280px .book-list grid-template-columns=296px 296px 296px 296px
in-stock background-color=rgb(21, 128, 61) color=rgb(255, 255, 255)
low background-color=rgb(251, 191, 36) color=rgb(28, 25, 23)
out background-color=rgb(231, 229, 228) color=rgb(28, 25, 23)
in-stock background-color=rgb(21, 128, 61) color=rgb(255, 255, 255)
Mixin or @extend?
In the previous post we saw placeholders and @extend: instead of copying declarations, it adds selectors to a single rule. On paper that means smaller CSS. In practice it has two limits. The first is media queries:
%card-surface {
border: 1px solid #d6d3d1;
border-radius: 8px;
}
.order-summary {
@media (width >= 48rem) {
@extend %card-surface;
}
}
Error: You may not @extend selectors across media queries.
╷
1 │ %card-surface {
│ ━━━━━━━━━━━━━ extended selector
... │
7 │ @media (width >= 48rem) {
│ ━━━━━━━━━━━━━━━━ extension @media
8 │ @extend %card-surface;
│ ^^^^^^^^^^^^^^^^^^^^^ extension
╵
03/extend-media.scss 8:9 root stylesheet
@extend adds the selector to the placeholder's rule; that rule is outside the media query and the extender is inside it. Sass cannot combine the two, because the result would either ignore the media query or copy the rule. Mixins do not have this problem.
The second is selector explosion. When @extend extends a class, it adds the new selector to every selector the class appears in:
.price {
font-variant-numeric: tabular-nums;
}
.book-card .price,
.cart .price,
.order-summary .price {
font-weight: 700;
}
.checkout-total .price-label {
color: #57534e;
}
// "a total is a price": extend the class
.checkout-total {
@extend .price;
}
.order-row__total {
@extend .price;
}
.price, .order-row__total, .checkout-total {
font-variant-numeric: tabular-nums;
}
.book-card .price, .book-card .order-row__total, .book-card .checkout-total,
.cart .price,
.cart .order-row__total,
.cart .checkout-total,
.order-summary .price,
.order-summary .order-row__total,
.order-summary .checkout-total {
font-weight: 700;
}
.checkout-total .price-label {
color: #57534e;
}
Three contexts and two extends became a nine-selector list. Nobody wrote a .cart .checkout-total rule, but now it exists and a total row inside the cart will be bold. In a real project .price appears in dozens of files and this list can grow to hundreds of selectors. The behavior does not stop at file boundaries either: a developer who writes a new rule containing .price in a file that the extending file loads with @use unknowingly carries the extending selectors there too.
My rule in Bookstore: @extend only with placeholders, only outside media queries, and only for rules that really are "the same thing" (like %visually-hidden). We never extend classes. If you need parameters, are inside a media query or are in doubt, use a mixin. Angular adds one more point: each component's stylesheet is compiled separately, so @extend cannot cross the component boundary anyway; two components share styles either through a mixin or through a global class.
In Production
Count deprecation warnings. slash-div is one of the most common in older code bases. Sass shows only the first five warnings of the same kind, then prints a line like WARNING: 7 repetitive deprecation warnings omitted. with how many it hid; you need --verbose to see them all. That number is the best measure of what you are dealing with. The sass-migrator division command turns most of them into math.div() automatically; we will use it in post 07 of the series.
Measure what your loops produce. Adding one step to the spacing scale and two sides to $sides takes 25 rules to 42; every color added to a palette means one more rule in every utility family. A loop that generates utility classes whether they are used or not is the exact opposite of Tailwind, which generates only the classes in use. In Bookstore we keep utilities limited to the sizes and sides we actually use.
Put validation into functions and mixins. @error and @warn run at build time and cost nothing at runtime. A build that breaks when a shared mixin gets a wrong argument is always cheaper than wrong CSS reaching production.
Common Pitfalls
Turning every repetition into a mixin. In Bookstore twelve components with a card look each got the same six declarations from a mixin. I wrote the same thing with a placeholder and compared the two outputs:
node scripts/03-sizes.mjs
surface-mixin 1717 bytes, gzip 223 bytes
surface-extend 298 bytes, gzip 212 bytes
The raw difference is six times, but after gzip it is only 11 bytes. Compression shrinks repeated text very well, so the size transferred over the network is almost the same. But the browser parses the uncompressed CSS and holds twelve separate rules for the mixin version, and when something needs to change you see the same value in twelve places in DevTools. Both have a better alternative: if these twelve components share the same look, a common .surface class or custom properties (--surface-border and the like) produce less CSS and can be changed at runtime. Use a mixin when you need parameters, or need to generate media queries or selectors.
Literal division producing invalid CSS. The padding: 12px / 2 at the start of this post turned into 12px/2 without any warning. Sass does not warn here because the same form appears in valid CSS such as font: 12px/1.5. Wherever you mean division, write math.div() or calc(); after Dart Sass 2.0 that is the only way anyway.
Forgetting & inside a mixin. If you write focus-ring with :focus-visible instead of &:focus-visible, the output is .cart-button :focus-visible; there is a space in between, and the rule targets a focused element inside the button rather than the button itself.
A Small Experiment
★☆☆ Add 5: 32px to $spacers in 03/utilities.scss and check how the rule count changes with npx sass 03/utilities.scss | grep -c '^\.'.
★★☆ Next to from(), write a between($min, $max) mixin that targets the range between two breakpoints. Raise an @error when $min is not smaller than $max, and generate the media query with the range syntax (48rem <= width < 64rem).
★★★ Rewrite surface-mixin.scss with custom properties: the mixin should produce only declarations bound to variables, like border: 1px solid var(--surface-border), with the values living on :root. Compare the raw and gzip sizes of the three versions (mixin, extend, custom properties) and write down how many lines you would have to change in each when switching to dark mode.
In the next post we move to Sass's module system: why @import is being removed, namespaces and private members with @use, an index file with @forward, migrating an old style tree with sass-migrator, and using shared Sass code in Angular components without duplicating CSS.