CSS preprocessors: SASS, SCSS, and LESS
CSS preprocessors compile an extended CSS syntax with variables, nesting, mixins, and functions into plain CSS that browsers can read.
Theory
TL;DR
- Preprocessors work like a word processor with mail merge: write structured templates with reusable parts, compile to plain CSS for the browser.
- SCSS is a strict superset of CSS (any valid CSS is valid SCSS); original SASS uses indentation instead of braces; LESS uses
@for variables and compiles via JS. - Main syntax split: SCSS and LESS use braces and semicolons; SASS uses indentation only.
- Decision rule: SCSS for teams (easiest CSS migration); LESS for dynamic JS-driven theming; skip both if CSS custom properties cover your needs.
- Bootstrap 5.3, Angular 18, and Bulma all ship with SCSS.
Quick example
// input.scss
$blue: #007bff;
.nav {
background: $blue;
.item {
padding: 1rem;
&:hover {
background: lighten($blue, 10%); // computes to #4dabf7
}
}
}/* Compiled output */
.nav { background: #007bff; }
.nav .item { padding: 1rem; }
.nav .item:hover { background: #4dabf7; }The compiler reads the nested structure, flattens it into standard selectors, and resolves all variable references in one pass.
Key difference
SCSS is 100% CSS-compatible. Paste any existing stylesheet into a .scss file and it compiles without changes. That alone makes migration painless. Original SASS trades braces and semicolons for significant whitespace, which some developers prefer for its minimal look. LESS looks similar to SCSS on the surface but uses @variable syntax, and its JS-based compiler can struggle with complex loops in large codebases. Most React projects I've worked on settled on SCSS once the component count grew past 20, simply because existing CSS snippets drop in without edits.
When to use
- Team project with existing CSS: SCSS - zero friction migrating files.
- Dynamic theming via JS (like Ant Design 5): LESS - its
@varsystem integrates naturally with JS config objects. - Small project or prototype: skip preprocessors, use CSS custom properties (
--color: red;) - supported in all major browsers since 2016. - Angular or React/Vite app: any of the three -
sass-loaderor Vite's built-in Sass support handles compilation. - Ruby stack: SASS - deepest ecosystem there.
Comparison table
| Feature | SASS (.sass) | SCSS (.scss) | LESS (.less) |
|---|---|---|---|
| Syntax | Indentation only | Braces + semicolons | Braces + semicolons |
| CSS superset | No | Yes (100%) | Yes (mostly) |
| Variables | $var | $var | @var |
| Mixins | @mixin | @mixin | .mixin() |
| Compilation | Ruby/Node | Ruby/Node/Dart | JS/Node/browser |
| Loops | @each, @for | @each, @for | Guards, recursive mixins |
| When to use | Solo Ruby devs | Teams, CSS migration | JS projects, dynamic vars |
How compilation works
You write .scss, .sass, or .less files. A compiler (Dart Sass for SCSS/SASS since v1.77+, lessc for LESS v4.1+) parses the source into an abstract syntax tree, resolves all variables and mixin calls in a single pass, then emits standard CSS. Tools like sass-loader for Webpack v14+ or Vite's built-in support watch files and recompile on save, outputting to /dist. Browsers never see the source files.
Common mistakes
Nesting more than 3-4 levels deep
// Wrong - produces .a .b .c .d .e { color: red; }
a { b { c { d { e { color: red; } } } } }Deeply nested selectors bloat compiled output and slow down selector matching. Chrome DevTools flags selectors beyond 6 levels. Keep nesting to 3 levels max, or use BEM: .nav__item--active { color: red; }.
Global variable overrides across files
// _theme.scss
$color: blue;
// _component.scss imports _theme.scss, then:
$color: red; // overrides globallyWebpack's partial recompilation can miss dependencies and produce inconsistent output. Use @use 'theme' as *; with Sass modules (v1.23+). It namespaces variables and computes each file only once.
Using @import instead of @use
@import dumps everything into the global namespace and re-executes the file every time it appears. @use loads the file once and namespaces its variables as theme.$primary. Dart Sass deprecated @import in v1.80.
Browser-compiling LESS
The old pattern of loading less.js in the browser produces no source maps and breaks on modern mixins. Always pre-compile with lessc or a bundler.
LESS lazy evaluation in loops
// LESS - guard-based loops can skip values unexpectedly
.loop(@n) when (@n > 0) {
.w-@{n} { width: ~"@{n}%"; }
.loop((@n - 5));
}
.loop(25);For predictable loops, SCSS @for is a cleaner option:
@for $i from 1 through 5 {
.w-#{$i * 5} { width: $i * 5%; }
}Real-world usage
- Bootstrap 5.3: SCSS variables and mixins for theming (
$primary,@mixin button-variant). - Ant Design 5: LESS for dynamic theming with JS-driven variable overrides.
- Angular 18: built-in SCSS support with
:hostcomponent nesting. - Bulma 1.0: pure SCSS with CSS custom property fallback.
- React/Vite:
sass-loaderplus SCSS modules per component.
Follow-up questions
Q: What is the difference between @mixin and @extend in SCSS?
A: @mixin copies the declared styles into every selector that includes it, which can bloat output. @extend makes multiple selectors share one rule block, which is leaner but order-sensitive. Use @extend for utility classes like .btn; use @mixin when you need to pass arguments.
Q: How do you handle vendor prefixes with SCSS?
A: Run Autoprefixer as a PostCSS plugin after compilation. It reads your browserslist config and adds -webkit-, -moz-, etc. automatically. The preprocessor handles logic; Autoprefixer handles prefixes.
Q: What is the difference between @use and @import in Sass?
A: @import pollutes the global namespace and re-executes the file on each import. @use loads the file once and exposes its members under a namespace (theme.$primary). Introduced in Dart Sass v1.23; @import is deprecated since v1.80.
Q: How does Dart Sass differ from LibSass?
A: Dart Sass is the current official implementation (v1.77+ as of 2024) and tracks the spec. LibSass was a C++ port deprecated in 2021 that lagged on @use and @forward. If you are still on node-sass, migrate to the sass package.
Q (senior): In a monorepo with 100+ SCSS partials, how do you prevent rebuild cascades?
A: Use @use instead of @import so each partial computes once. Combine with Vite's ?inline imports for component-level HMR. Profile slow builds with sass --trace and avoid glob imports - they break dependency tracking.
Examples
Themeable navbar with breakpoints
A React app needs a navbar that adapts to screen size and pulls colors from a shared theme file.
// styles/navbar.scss
$primary: #0d6efd;
$breakpoint-md: 768px;
@mixin flex-center {
display: flex;
align-items: center;
}
.navbar {
@include flex-center;
padding: 1rem;
background: $primary;
@media (min-width: $breakpoint-md) {
padding: 1.5rem;
}
.logo { font-size: 1.5rem; }
&__link {
color: white;
text-decoration: none;
&:hover {
color: lighten($primary, 20%);
}
}
}/* Compiled output (simplified) */
.navbar { display: flex; align-items: center; padding: 1rem; background: #0d6efd; }
@media (min-width: 768px) { .navbar { padding: 1.5rem; } }
.navbar .logo { font-size: 1.5rem; }
.navbar__link { color: white; text-decoration: none; }
.navbar__link:hover { color: #6ea8fe; }The mixin handles flex setup, $primary is declared once at the top, and the media query is colocated with the component it affects - not buried in a separate breakpoints file.
Sass modules with @use
This pattern replaces @import in any Dart Sass project (v1.23+) and is the current recommended approach.
// _theme.scss
$primary: #0d6efd;
$danger: #dc3545;
$font-size-base: 1rem;// button.scss
@use 'theme';
.btn {
font-size: theme.$font-size-base;
background: theme.$primary;
&--danger {
background: theme.$danger;
}
}The theme. prefix makes it clear where each variable originates. No more hunting through 50 partials to find which file last set $primary.
Control flow: SCSS vs LESS
Generating spacing utility classes with a loop is a common task. Here is how both preprocessors handle it.
// SCSS - predictable @for loop
@for $i from 1 through 5 {
.mt-#{$i} {
margin-top: $i * 0.25rem; // 0.25, 0.5, 0.75, 1, 1.25rem
}
}// LESS - recursive mixin guard (equivalent result)
.spacing-loop(@n) when (@n > 0) {
.mt-@{n} { margin-top: (@n * 0.25rem); }
.spacing-loop((@n - 1));
}
.spacing-loop(5);Both produce the same five classes. The SCSS version reads as a standard loop; the LESS version requires understanding guard-based recursion before you can safely modify it.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.