Frontend Handbook
Written

The CSS pipeline

How CSS gets into the bundle, what Sass compiles to, PostCSS plugins, how CSS Modules scope class names, and how Tailwind generates only the utilities you use, each with a live demo.

A bundler only understands JavaScript, so import "./styles.css" needs help: a CSS step that compiles languages such as Sass, rewrites the CSS with PostCSS plugins, scopes class names, generates utilities, minifies, and finally decides how the result reaches the browser. In Next.js 16 most of this is Lightning CSS and PostCSS behind Turbopack; in Vite and webpack the same jobs are done by a chain of plugins or loaders.

Where the real tool can run in a browser, the demos use it: PostCSS and the Tailwind compiler are downloaded when you reach them (code splitting, from the previous page). The Sass compiler is a small stand-in checked against Dart Sass, and the rendering demos run in real frames.

01

How CSS reaches the page

A bundler can deliver CSS in two ways. In development it usually turns each CSS file into a JavaScript module that inserts a <style> tag, so it can hot-swap styles without a reload. In production it extracts the CSS into real .css files linked from the HTML.

  • A stylesheet <link> in the <head> is render-blocking: the browser waits for it before the first paint, so the page never appears unstyled (see what blocks rendering).
  • Styles inserted by JavaScript arrive only after the script has downloaded and run. If the HTML has painted by then, users see a flash of unstyled content (FOUC). Runtime CSS-in-JS libraries have the same problem unless they also render the styles on the server.
  • CSS keeps import order: the bundler concatenates files in the order the modules import them, and with equal specificity the later rule wins. Reordering imports can change how a page looks.
  • Extracted CSS gets content hashes like JavaScript does, and is split per route or chunk, so a page downloads only the styles its components import.

Try it: load the same page both ways

How the CSS arrives

Watch the frame as it loads.

The sample page loads here.

Paint times come from the frame's own Paint Timing (browsers don't paint a frame that is off screen, so it scrolls into view). The 900 ms before injected styles stands in for downloading and running the JavaScript bundle; an extracted stylesheet blocks rendering until it has loaded, so the browser never paints without it.

  • Do: Ship extracted, hashed stylesheets in production, or render critical CSS on the server.

    Avoid: Judging a page's styling from the dev server: injected styles can flash there but not in production, and the other way round.

Read more:web.dev: Render-blocking CSS(opens in a new tab)Next.js: CSS(opens in a new tab)Vite: CSS(opens in a new tab)

02

Sass: a language that compiles to CSS

Sass (SCSS syntax) is a language that compiles to CSS. Everything it adds exists only at build time: the browser never sees a variable, a mixin or a nested rule, just the flat CSS they produce.

  • $variables are replaced by their values when compiling, so they can't change at runtime. CSS custom properties (--brand) can, which is why design systems now use them for theming.
  • Nesting joins selectors: & is the parent selector, so &__title becomes .card__title and .dark & becomes .dark .card. A nested @media bubbles up and wraps the parent selector.
  • Mixins paste their declarations wherever they are included: convenient, but each @include repeats the CSS.
  • Browsers now support CSS nesting natively, so for many projects the main reason to use Sass is gone; build tools can still flatten nesting for older browsers (next topic).

Try it: edit SCSS and read the CSS it becomes

Sample
styles.cssmeasuring…
.card {
  border-radius: 8px;
  padding: 1rem;
}
.card__title {
  color: #6633cc;
  font-weight: 600;
}
.card:hover {
  box-shadow: 0 2px 8px #c8c8d0;
}
.dark .card {
  background: #1f1f2e;
}
@media (min-width: 40em) {
  .card {
    padding: 2rem;
  }
}

Simplified compiler for a subset of SCSS (variables, nesting, &, @media, @mixin/@include); its output for these samples is the same as Dart Sass 1.93. Not supported: @use, @extend, control flow, functions and arithmetic.

  • Do: Use custom properties for values that change (themes, dark mode) and Sass variables only for build-time constants.

    Avoid: Deep nesting: every level lengthens selectors and raises specificity.

Read more:Sass: Basics(opens in a new tab)MDN: CSS nesting(opens in a new tab)

03

PostCSS: CSS as a syntax tree

PostCSS does to CSS what Babel does to JavaScript: it parses the stylesheet into a syntax tree, hands the tree to a list of plugins in order, and prints the result. PostCSS itself changes nothing; every behavior comes from a plugin.

  • The tree has few node types: Root, Rule (a selector and its block), AtRule (@media…), Declaration and Comment. A plugin visits the nodes it cares about and edits them.
  • Autoprefixer, postcss-preset-env (future CSS for today's browsers), cssnano (minifying) and Tailwind CSS v3 are all PostCSS plugins; Tailwind v4 ships one too, which is how this site builds its CSS.
  • Order matters: flatten nesting before plugins that only understand flat rules, and minify last.
  • Lightning CSS, written in Rust, does parsing, prefixing, nesting and minifying in one fast pass. Next.js 16 uses it for CSS Modules and nesting, and Vite 8 for minifying; both still run PostCSS for the plugins you configure.

Try it: pick a node, toggle the plugins

Pick a node in the tree to see its source.

PostCSS's syntax tree (the real parser, version 8.5):

Loading PostCSS…

Plugins, run in this order

The plugins are small ones written for this page with PostCSS's plugin API; the prefix table is simplified (Autoprefixer uses Can I Use data and your browserslist).

  • Do: Let your browserslist decide prefixes and lowering, and keep the PostCSS config to the plugins you need.

    Avoid: Writing vendor prefixes by hand: they go stale and drift from what your targets need.

Read more:PostCSS: Writing a plugin(opens in a new tab)Autoprefixer(opens in a new tab)Lightning CSS(opens in a new tab)

04

CSS Modules: scoped class names

Every class in plain CSS is global: two components that both define .title fight, and whichever stylesheet loads later wins for both. CSS Modules fix this at build time by renaming each class to something unique to its file.

  • Importing Card.module.css gives the component an object that maps the names you wrote to the generated ones: styles.title is "Card_title__3f9a1". Only that file's rules use that name.
  • :global(.dark) opts a selector out of renaming, for classes set elsewhere (a theme class on <html>). composes: base adds another class from the module to this one's export.
  • Scoping is plain CSS at runtime: nothing runs in the browser, unlike runtime CSS-in-JS.
  • Next.js supports .module.css out of the box; this site uses Tailwind utilities instead, which avoid the problem by not naming components at all.

Try it: render two components with the same class name

How the two files are imported

Building…

Rendering…

The frame is a real browser rendering the CSS; the colors are read back with getComputedStyle. Names are [file]_[class]__[hash], the hash being the first characters of the SHA-256 of the file path and class: one common scheme, as tools differ.

  • Do: Give each component its own .module.css, and use :global only for classes you don't control.

    Avoid: Relying on stylesheet import order to settle conflicts between components.

Read more:CSS Modules(opens in a new tab)Next.js: CSS Modules(opens in a new tab)

05

Tailwind: generating only what you use

Tailwind defines thousands of possible utility classes but ships only the ones you use. It doesn't parse your JavaScript: it reads your source files as text, collects every string that could be a class (candidates), and generates CSS for the candidates that are real utilities.

  • Anything that isn't a utility (className, div, btn-primary) is ignored, which is why scanning everything as text is safe.
  • A class built from pieces at runtime, such as `bg-${color}-500`, never appears whole in the source, so it is never generated. Write the full names, for example in a lookup object.
  • Tailwind v4 writes utilities against theme variables (padding: calc(var(--spacing) * 4)) and emits only the variables that are used.
  • Variants such as hover: and md: wrap the rule in a selector or media query, so the order of utilities in the output is decided by Tailwind, not by your class attribute.

Try it: edit markup and watch the real Tailwind output

Preview, styled only by the CSS Tailwind generated:

Loading Tailwind 4.3.3…

The real Tailwind compiler runs in your browser on the candidates. The candidate scanner here is simplified; Tailwind's own (Oxide) follows the same rule: a class name must appear whole somewhere in your files.

  • Do: Keep full class names in the source, and map dynamic values to complete classes.

    Avoid: Concatenating class names from pieces, or safelisting huge ranges of classes just in case.

Read more:Tailwind: Detecting classes in source files(opens in a new tab)Tailwind: Theme variables(opens in a new tab)