Module 8: Advanced CSS & Architecture
CSS Preprocessors: Sass/SCSS and Less
What a preprocessor adds on top of CSS, how SCSS features compile to plain CSS, and an honest view of what native CSS now covers.
A preprocessor is a language that compiles to CSS. You write in its syntax, run a compiler, and ship the generated .css file. The browser never sees the original source.
The two names you will meet are Sass (with the .scss syntax, which is a superset of CSS, so any valid CSS is valid SCSS) and Less. Sass has far more adoption today, and the reference implementation is Dart Sass. This lesson uses SCSS.
styles.scss ──► sass compiler ──► styles.css ──► browser
(your source) (plain CSS)
Why they were invented#
For many years, CSS had no variables, no nesting, and no way to split code across files without extra requests. Preprocessors filled those gaps. Some of that has since moved into CSS itself, which is why this lesson also covers what to use today.
Variables#
$brand: #0b5fff;
$radius: 8px;
.btn {
background: $brand;
border-radius: $radius;
}
Output:
.btn {
background: #0b5fff;
border-radius: 8px;
}
The $brand is gone in the output. It is a compile-time substitution, so you cannot change it in the browser. That is the key difference from CSS custom properties, which exist at runtime. In practice teams often use both: Sass variables for build-time values like breakpoints (which cannot go in a var() inside a media query) and CSS variables for anything that changes at runtime, like themes.
Nesting#
.nav {
display: flex;
a {
padding: 0.5rem 1rem;
&:hover { text-decoration: underline; }
&.active { font-weight: 700; }
}
&__logo { margin-right: auto; }
}
Compiles to:
.nav { display: flex; }
.nav a { padding: 0.5rem 1rem; }
.nav a:hover { text-decoration: underline; }
.nav a.active { font-weight: 700; }
.nav__logo { margin-right: auto; }
& stands for the parent selector. &:hover attaches to it, &.active attaches a class to it, and &__logo builds a BEM-style name by appending text to it.
The danger is going too deep. Each level multiplies selector length and specificity:
.page { .content { .article { .body { p { a { color: red; } } } } } }
/* compiles to .page .content .article .body p a — fragile and heavy */
A common guideline is to nest at most two or three levels. If the output selector is longer than you would write by hand, flatten it.
Partials and modules#
Split code into files that start with an underscore (they are not compiled on their own) and load them with @use:
scss/
├── main.scss
├── abstracts/
│ ├── _variables.scss
│ └── _mixins.scss
├── base/
│ └── _reset.scss
└── components/
├── _button.scss
└── _card.scss
// main.scss
@use "abstracts/variables" as vars;
@use "abstracts/mixins" as m;
@use "base/reset";
@use "components/button";
@use "components/card";
.hero { padding: vars.$space-lg; }
@use is the modern replacement for @import, which Sass has deprecated. A module loaded with @use has its own namespace (vars.$space-lg), so names do not collide, and it is loaded only once. The files are combined at compile time into one CSS output, with no extra browser requests.
(In Sass, // comments work and are removed from the output. In plain CSS they do not exist.)
Mixins#
A mixin is a reusable block of declarations, optionally with parameters.
@mixin flex-center($gap: 0) {
display: flex;
align-items: center;
justify-content: center;
gap: $gap;
}
@mixin from($bp) {
@media (min-width: $bp) { @content; }
}
.card {
@include flex-center(1rem);
@include from(768px) {
padding: 2rem;
}
}
Compiles to:
.card { display: flex; align-items: center; justify-content: center; gap: 1rem; }
@media (min-width: 768px) { .card { padding: 2rem; } }
@content lets the mixin wrap whatever block you pass in, which is how breakpoint mixins keep media queries next to the component styles.
Functions, maps and loops#
@use "sass:math";
$breakpoints: (sm: 640px, md: 768px, lg: 1024px);
@function rem($px) { @return math.div($px, 16) * 1rem; }
@each $name, $size in $breakpoints {
.hide-below-#{$name} {
@media (max-width: $size - 1px) { display: none; }
}
}
.title { font-size: rem(32); } // 2rem
Loops and functions are where preprocessors still do things CSS cannot: generating many utility classes from a data map, or doing build-time math. Note math.div instead of / for division. The slash is now reserved for separating values, like font: 16px/1.5.
@extend, and why to be careful#
%message { padding: 1rem; border-radius: 4px; }
.success { @extend %message; color: green; }
.error { @extend %message; color: red; }
It compiles to a combined selector (.success, .error { padding: 1rem; ... }). It looks efficient, but it reorders CSS in surprising ways and can produce large selector lists. Most style guides prefer mixins, or just shared classes in the HTML.
Less, briefly#
Less has similar ideas with different syntax: variables use @, mixins are written like classes, and nesting is the same.
@brand: #0b5fff;
.rounded(@r: 8px) { border-radius: @r; }
.btn { background: @brand; .rounded(); }
Less is less common in new projects (Bootstrap 3 and Ant Design in older versions used it). If you join a codebase that uses it, the concepts transfer directly.
What native CSS now covers#
| Preprocessor feature | Native CSS today |
|---|---|
| Variables | custom properties (--x, var()) |
| Nesting | native nesting with &, supported in all current browsers |
| Math | calc(), min(), max(), clamp() |
| Color manipulation | color-mix() and relative color syntax |
| Modules and partials | @import with bundling, or a build tool |
| Loops, functions, maps | not available |
Native nesting looks nearly the same as Sass:
.card {
padding: 1rem;
&:hover { box-shadow: 0 4px 12px rgb(0 0 0 / 0.15); }
& .title { font-weight: 700; }
}
For a small or new project, you may not need a preprocessor at all. Add one when you want loops, functions, build-time maps, or when your team already depends on it.
Compiling#
npm install --save-dev sass
npx sass --watch scss/main.scss:dist/main.css
npx sass scss/main.scss dist/main.css --style=compressed
Most bundlers (Vite, webpack, Parcel) compile Sass automatically when you import a .scss file.
Common mistakes#
- Nesting too deeply, which creates long selectors and high specificity.
- Using Sass variables for things that must change at runtime, such as themes.
- Using
@importinstead of@usein new code. @extendoveruse, producing strange output order and huge selectors.- Using
/for division, which is deprecated and behaves unexpectedly. - Never looking at the compiled output. Open the generated CSS regularly. If it looks bloated, your source is.
- Mixins that generate big blocks repeatedly, inflating file size.
Practice#
- Convert a small plain CSS file to SCSS with variables, nesting (two levels maximum) and one mixin. Compile it and compare the output with your source.
- Create a
from($bp)mixin and use it to write a mobile-first component. - Generate a set of spacing utilities (
.mt-1,.mt-2, and so on) with an@eachloop over a map. - Rewrite your nested SCSS in native CSS nesting and variables and decide what you lose and gain.