Let's be honest. Most SCSS files in most projects look like this:
$primary: #ff69b4;
$border-radius: 8px;
.card {
color: $primary;
border-radius: $border-radius;
&__title {
font-size: 18px;
}
}
Variables. Nesting. Maybe a mixin or two. That's it.
Meanwhile, this is also SCSS — a music visualizer with 16 independently animated bars, over 1,600 generated keyframe declarations, and zero JavaScript:
That's ~30 lines of SCSS generating what would take hundreds of lines to write by hand. It uses features that most developers never touch: @for, random(), nested loops, dynamic @keyframes.
SCSS is a programming language. Let me show you the 90% you're probably not using.
Each — Iterate Over Data Structures
The problem: Typography styles are a set of properties that always go together — font-size, font-weight, line-height. You define 10 variants and write each one out manually. Add a property to a variant — hope you didn't miss a spot.
Store font styles as maps and apply them with a single mixin:
$body-16R: (
'font-weight': 400,
'font-size': 16px,
'line-height': 1.6
);
$body-14R: (
'font-weight': 400,
'font-size': 14px,
'line-height': 1.4
);
@mixin font-style($font-type, $color: null) {
@each $key, $value in $font-type {
#{$key}: $value;
}
color: $color;
}
.size {
&14 {
@include font-style($body-14R);
}
&16 {
@include font-style($body-16R);
}
}
The mixin doesn't care how many properties are in the map — it iterates over whatever you give it. Add letter-spacing to the map? The mixin picks it up automatically. No changes anywhere else.
Works great for generating design system tokens too:
$icon-sizes: (sm: 16px, md: 24px, lg: 32px, xl: 48px);
@each $name, $size in $icon-sizes {
.icon-#{$name} {
width: $size;
height: $size;
}
}
One map, one loop — four consistent icon classes. New size? Add one line to the map.
If / Else — Conditional Logic at Build Time
The problem: Large headings need tight line-height. Body text needs loose line-height. You set these manually everywhere and they inevitably drift out of sync.
Let the mixin decide automatically:
@mixin font-size($size) {
font-size: $size;
@if $size > 24px {
line-height: 1.2;
} @else if $size > 16px {
line-height: 1.4;
} @else {
line-height: 1.6;
}
}
h1 { @include font-size(36px); } // line-height: 1.2
h2 { @include font-size(22px); } // line-height: 1.4
p { @include font-size(16px); } // line-height: 1.6
One rule, consistent typography across the entire project. The relationship between font-size and line-height is defined once — not scattered across 50 selectors.
Conditionals also help reduce mixin boilerplate. Here's one for pseudo-elements:
@mixin pseudo-element(
$pseudo-element: before,
$content: '',
$position: relative,
$display: block
) {
@if ($pseudo-element == all) {
&:after,
&:before {
position: $position;
display: $display;
content: $content;
@content;
}
}
@else {
&:#{$pseudo-element} {
position: $position;
display: $display;
content: $content;
@content;
}
}
}
// Both pseudo-elements at once
.decorated {
@include pseudo-element(all) {
width: 10px;
height: 10px;
background-color: #a8efda;
}
}
// Just ::after
.arrow {
@include pseudo-element(after) {
border: 5px solid transparent;
border-top-color: #333;
}
}
Pass all — get both. Pass before or after — get just one. The @content directive lets you inject custom styles. One flexible mixin instead of copy-pasting pseudo-element boilerplate.
Functions — Compute Values Instead of Hardcoding
The problem: Magic numbers. Why is the padding 24px? Because someone picked it. Now the design team wants tighter spacing — go find and replace through every file.
Define a spacing system as a function:
$base-spacing: 8px;
@function space($multiplier) {
@return $base-spacing * $multiplier;
}
.card {
padding: space(3); // 24px
margin-bottom: space(2); // 16px
gap: space(1.5); // 12px
}
.modal {
padding: space(4); // 32px
margin: space(6) auto; // 48px auto
}
Every spacing value traces back to one base unit. Want to tighten the whole UI? Change $base-spacing from 8px to 6px — everything recalculates.
Another common pain point — px to rem conversion:
$root-font-size: 16px;
@function rem($px) {
@return ($px / $root-font-size) * 1rem;
}
h1 { font-size: rem(32px); } // 2rem
h2 { font-size: rem(24px); } // 1.5rem
.small { font-size: rem(12px); } // 0.75rem
No more mental math. No more incorrect rem values from eyeballing the conversion. The function does it right every time, and if the root font size changes, every rem value stays correct.
Maps — Structured Data as a Single Source of Truth
The problem: Z-index chaos. One developer sets z-index: 999, another uses z-index: 9999 to go above it, a third adds z-index: 99999. Nobody knows the stacking order anymore.
Put it all in one map:
$z-layers: (
dropdown: 100,
sticky: 200,
modal: 300,
tooltip: 400,
toast: 500,
);
.dropdown { z-index: map-get($z-layers, dropdown); }
.modal { z-index: map-get($z-layers, modal); }
.tooltip { z-index: map-get($z-layers, tooltip); }
The full stacking order visible at a glance. New layer needed? Add it to the map in the right position. No more z-index arms race.
Maps also support operations — merge, remove, lookup:
$sizes: (40: 40px, 50: 50px, 80: 80px);
$extra: (60: 60px);
// Compose data before generating CSS
$all-sizes: map-merge($sizes, $extra);
$final-sizes: map-remove($all-sizes, 40);
@each $size, $value in $final-sizes {
.icon-#{$size} {
width: $value;
height: $value;
}
}
You can build up, combine, and filter your data structures before generating any CSS from them. And map-has-key lets you check if a key exists before accessing it — defensive programming in your stylesheets:
$font-weights: ("regular": 400, "medium": 500);
.title {
@if (map-has-key($font-weights, "bold")) {
font-weight: map-get($font-weights, "bold");
} @else {
font-weight: map-get($font-weights, "medium"); // safe fallback
}
}
For — Generate Numbered Sequences
The problem: You need a grid system. Writing .col-1 through .col-12 by hand is exactly the kind of work a loop should do.
$columns: 12;
@for $i from 1 through $columns {
.col-#{$i} {
width: percentage($i / $columns);
}
}
12 classes from 4 lines. Need a 10-column grid? Change one number. This is the same principle behind every CSS framework's grid.
Loops really shine when math is involved. I built an analog clock entirely with SCSS — the face needs 30 tick divs, each with ::before and ::after pseudo-elements, giving 60 tick marks total. Each pair is rotated by exactly 6 degrees:
@for $i from 1 through 30
.item__minute:nth-child(#{$i})
$rotate: $i * 6deg
transform: translate(-50%, -50%) rotate($rotate)
60 tick marks at precise angles from 4 lines. Every 5th marker gets a longer tick for hours via &:nth-child(5n). The clock hands are pure CSS @keyframes — JavaScript only calculates the starting position once, then CSS handles all movement.
The clock also uses the SCSS Maps + CSS Custom Properties pattern from my previous article — it has 4 time-based themes (morning, day, evening, night) that switch automatically based on the current hour. All theme tokens live in a single $modes map.
See the live demo → | Source code on GitHub
Staggered animations — another common use:
@for $i from 1 through 8 {
.fade-in:nth-child(#{$i}) {
animation-delay: $i * 0.1s;
}
}
Each item fades in 100ms after the previous one. The pattern extends automatically.
How the Visualizer Works
Now you've seen all the building blocks, let's come back to that audio visualizer from the intro:
.wrap {
height: 50px;
overflow: hidden;
display: flex;
align-items: center;
div {
height: 100%;
width: 4px;
background-color: red;
margin: 0 1px;
border-radius: 4px;
will-change: transform;
}
@for $i from 1 through 16 {
div {
&:nth-of-type(#{$i}) {
animation: pulse-#{$i} 16s infinite ease-in-out;
@keyframes pulse-#{$i} {
$randomNumber: random(100) + 0%;
0% {
transform: translateY($randomNumber);
}
@for $step from 1 through 99 {
#{$step + 0%} {
$stepRandomNumber: random(100) + 0%;
transform: translateY($stepRandomNumber);
}
}
100% {
transform: translateY($randomNumber);
}
}
}
}
}
}
Every feature from this article is at work:
- Outer for-loop — creates 16 bars with unique animations
- Inner for-loop — generates 99 keyframe steps per animation
- random() — gives each step a different translateY value
- String interpolation — builds dynamic animation names and keyframe percentages
The compiled CSS: 16 animations × 100 steps = 1,600+ transform declarations. Since random() runs at compile time, every build produces a different animation pattern.
Should You Use All of This?
The space() function, z-layers map, and @each typography mixin are everyday tools — use them tomorrow. The audio visualizer is an extreme example, but it shows the ceiling of what's possible when you treat SCSS as a real language.
Next time you're about to copy-paste a block and change one value, ask yourself — could a loop, a function, or a map handle this?
Stop writing CSS by hand when SCSS can generate it for you.
If you're curious how maps and CSS custom properties work together for runtime theming, I covered that in my previous article.
What SCSS features do you use beyond variables and nesting? Let me know in the comments.
Top comments (0)