21 — Preprocessors & Build Tools: Sass, PostCSS, Vite & CSS Modules
Sass extends CSS with compile-time variables, nesting, mixins, and loops. PostCSS transforms standard CSS with plugins. CSS Modules scope class names per file. The modern pipeline: CSS variables (runtime) + a build tool (Vite) + PostCSS (prefixes/minify). Add Sass only if you need mixins/loops.
Sass (SCSS) — variables, nesting, mixins, loops
$primary: #3498db; // compile-time constant → becomes #3498db in the output. Not runtime.
$spacing: 1rem;
.card {
padding: $spacing;
&__title { font-size: 1.5rem; color: $primary; } // BEM with nesting
&--featured { border-color: $primary; }
}
@mixin flex-center { display: flex; align-items: center; justify-content: center; }
.hero { @include flex-center; min-height: 100vh; }
@function rem($px) { @return $px / 16px * 1rem; } // build-time function
.title { font-size: rem(24px); } // → 1.5rem
@for $i from 1 through 12 { .col-#{$i} { width: ($i / 12) * 100%; } } // loop → 12 classes
@use (modern) vs @import (deprecated)
// _variables.scss
$primary: #3498db;
// main.scss
@use 'variables' as *; // imports $primary into scope. Scoped, loads once.
// or
@use 'variables'; // access as variables.$primary (namespaced)
// _index.scss (barrel)
@forward 'variables';
@forward 'mixins';
The Sass-vs-CSS-Variables Trap — compile-time ≠ runtime
Anti-pattern: Sass variable in @media can't change
// ❌ Sass variables are COMPILE-TIME. $primary: red inside @media changes it only within that
// block's scope. By the time .button is compiled, $primary is blue (the main-scope value).
$primary: blue;
@media (max-width: 768px) { $primary: red; } // scoped to the @media block, doesn't persist
.button { background: $primary; } // → background: blue; (no media query, no change)
// ✓ CSS variables are RUNTIME — they respond to @media at runtime.
:root { --primary: blue; }
@media (max-width: 768px) { :root { --primary: red; } }
.button { background: var(--primary); } // blue desktop, red mobile — at runtime
PostCSS — the plugin pipeline
// postcss.config.js
module.exports = {
plugins: [
require('autoprefixer'), // vendor prefixes (browserslist-driven)
require('postcss-preset-env')({ stage: 2 }), // future CSS → today's CSS
require('cssnano')({ preset: 'default' }), // minify
],
};
Vite — the modern build tool
// vite.config.js — handles CSS/SCSS/PostCSS out of the box.
export default {
css: {
preprocessorOptions: {
scss: { additionalData: `@use "variables" as *;` }, // auto-import for every SCSS file
},
},
};
CSS Modules — scoped class names
/* Button.module.css — class names are scoped to this file (compiled to unique names). */
.btn { padding: 0.5rem 1rem; }
.primary { background: blue; }
import styles from './Button.module.css';
<button className={`${styles.btn} ${styles.primary}`}>Click</button>
// Compiled: class="Button_btn__3a2f1 Button_primary__9b1c4" — no collisions, no BEM needed.
When to Use What
Tool Use for
CSS variables Runtime theming, JS-accessible, @media-responsive
Sass variables Build-time math, loops, mixins (compiled away)
PostCSS Prefixes, future CSS, minification, Tailwind
CSS Modules Scoped component styles (React)
Tailwind Utility-first design system
Vite Dev server + bundling (modern default)
💡 Tips & Tricks
- Idiom: CSS variables for theming (runtime, JS-accessible, @media-responsive) + Sass variables for build-time math/mixins — they're complementary.
--primarychanges at runtime;$breakpointis a compile-time constant for loops. - Idiom:
@use(not@import) in modern Sass —@importis deprecated (pollutes global scope, loads multiple times).@useis scoped and loads once.@forwardre-exports from a barrel. - Idiom: PostCSS for prefixes + minification (Autoprefixer + cssnano) — configure once, forget. Vite/webpack integrate it.
- Idiom: CSS Modules for scoped component styles in React —
import styles from './X.module.css'gives locally-scoped names (no collisions, no BEM). For Vue, use<style scoped>. - Idiom: Vite for new projects — handles CSS/SCSS/PostCSS out of the box, native ES modules in dev (instant startup), Rollup for production. Faster and simpler than webpack.
⚠️ Edge Cases & Gotchas
- Sass
@importis deprecated: use@use/@forward.@importpollutes the global namespace and loads files multiple times. - Sass variables are compile-time:
$primary: bluebecomesbluein the compiled CSS — can't change at runtime or per media query. Use CSS variables (--primary) for runtime theming. @usenamespaces by default:@use 'variables'→variables.$primary. Use@use 'variables' as *for no namespace, oras vfor a short one.- CSS Modules need a framework: they work via the bundler (React/Vue/Next.js). A plain
.htmlfile can't use them (no scoping mechanism). @applyin Tailwind couples CSS to Tailwind:@apply mt-4;makes your CSS dependent on Tailwind's utilities. Use sparingly; prefer composing in JSX/HTML.- webpack loaders execute right-to-left:
['style-loader', 'css-loader', 'postcss-loader', 'sass-loader']→ sass-loader first (SCSS→CSS), then postcss-loader, then css-loader, then style-loader (inject). - Minification can break dynamic class names: aggressive minification that removes "unused" CSS can misidentify JS-generated classes. Configure PurgeCSS/Tailwind safelist carefully.
🧠 Spot the Bug
$primary: blue;
@media (max-width: 768px) { $primary: red; }
.button { background: $primary; }
Answer
Sass variables are compile-time. $primary: red inside the @media block changes the variable only within that block's scope. By the time .button { background: $primary; } is compiled, $primary is blue (the main-scope value). The compiled CSS is background: blue; with no media query — the red never applies. Sass variables can't change at runtime or per viewport. Fix: use CSS variables — :root { --primary: blue; } + @media (max-width: 768px) { :root { --primary: red; } } + background: var(--primary). CSS variables are runtime and @media-responsive.