
By the end of this article, you will be able to:
bg-blue-500, px-4, that sort of thing)You write a button in Tailwind:
<button class="bg-blue-500 text-white px-4 py-2 rounded-lg">
Submit
</button>You open the browser. It looks right. You inspect it in Chrome DevTools and see the button's CSS comes from an app.css file, which contains exactly these rules:
.bg-blue-500 { background-color: #3b82f6; }
.text-white { color: #fff; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.rounded-lg { border-radius: 0.5rem; }Three questions immediately arise:
bg-red-100 through bg-red-900, p-0 through p-96…) — so why does the final CSS only contain 5 rules?.bg-blue-500 rules generated? Every time I change my HTML? Only when I run npm run build?justify-between somewhere, how does Tailwind know I used it?These three questions all point to the same core issue: Tailwind's build timing and generation mechanism. And the answers are completely different in v3 and v4.
Before we dive into Tailwind, let's answer a more fundamental question: why does CSS need a build step at all?
Consider this source code:
@import "tailwindcss";
.my-class {
@apply bg-blue-500 px-4;
}A browser looking at this CSS sees three things it doesn't understand:
@import "tailwindcss" — the browser has no idea what module tailwindcss is@apply — this isn't valid CSS syntax at allbg-blue-500 — no CSS rule defines this classSo between your source CSS and the CSS the browser actually needs, there must be a translation layer. This layer runs at build time and translates your Tailwind source into plain CSS that browsers can consume.
Tailwind v3 and v4 took completely different approaches to designing this translation layer.
Before we get to Tailwind, there's something we need to establish: when you write import './index.css', what does webpack actually do with it?
This involves a pipeline — webpack doesn't natively understand .css files, so it chains together several loaders, each with a specific role:
When a CSS file enters webpack, the first loader it usually hits is postcss-loader. This loader does one simple thing: hand the CSS text to PostCSS, let PostCSS run a series of plugins on it, then get the transformed text back.
PostCSS itself doesn't do much — it's just a "dispatch center." The real work is done by the plugins riding on top of it: Tailwind, Autoprefixer (which adds browser prefixes), cssnano (minification), and so on.
At this stage, the CSS is still plain text — and that's the whole point. PostCSS needs to see @tailwind, @import, @apply, and other browser-incompatible syntax in their raw form, before downstream loaders get their hands on them.
After postcss-loader, the CSS text reaches css-loader. Its job:
@import './other.css' and url('./bg.png') within the CSS — webpack doesn't natively understand theserequire('./other.css'))After css-loader, the CSS is no longer plain text — it's a JS module:
// Inside a webpack module (pseudocode)
const css = '.button { background: url(' + require('./bg.png') + '); }';
module.exports = css;Now the CSS is a JS string, but the browser still can't see it. The final loader's job is to deliver it to the browser. Two approaches:
style-loader (development mode): dynamically creates a <style> tag, injects the CSS string into it, and inserts it into the page's <head>. Those inline <style> tags you see in DevTools? That's style-loader at runtime — they're not standalone .css files.MiniCssExtractPlugin (production mode): extracts all the CSS that flows through, merging it into one or more standalone .css files. The browser loads them via <link rel="stylesheet">, and they can be cached. You'd never use style-loader in production — inline <style> tags can't be cached or CDN-optimized.main.js
├── import './index.css' ← webpack encounters CSS, starts the loader chain
│ │
│ ▼
│ [postcss-loader] ← Tailwind scans + generates while CSS is still text
│ │ Autoprefixer also runs here
│ ▼
│ [css-loader] ← Converts @import / url() to webpack module refs
│ │ Output: a JS string
│ ▼
│ [style-loader] ← Dev: injects <style> tags into DOM
│ [MiniCssExtractPlugin] ← Prod: extracts .css files
│
├── import './App.jsx' ← JS processed normally
├── import './Button.jsx' ← JS processed normally
│ └── import './Button.css' ← Also goes through postcss-loader, but Tailwind ignores it (more on this below)
└── ...Why does postcss-loader come first? Because PostCSS operates on plain text — it needs to see raw @import, @tailwind, etc. If css-loader ran first, @import would already be resolved into JS module references, and PostCSS would see nothing useful.
Tailwind v3 is a PostCSS plugin — it's called during the postcss-loader stage. But its behavior is unusual: it doesn't "do work" on every CSS file that passes through.
When postcss-loader processes Button.css, the Tailwind plugin glances at it — no @tailwind directive here — and passes it through untouched.
When postcss-loader processes index.css (the entry CSS), the Tailwind plugin sees @tailwind utilities — this is the trigger. And the way it fires is special: instead of continuing forward, it pauses the current processing and performs a global scan of your entire project's source code.
That's the core of Tailwind v3's workflow, broken into three steps:
This is the most critical step. Tailwind needs to figure out which classes you actually wrote in your source code.
In v3, you tell Tailwind where to scan via tailwind.config.js:
// tailwind.config.js
module.exports = {
content: [
'./src/**/*.{html,js,jsx,ts,tsx}', // scan these files
],
theme: {
extend: {},
},
plugins: [],
};When you run npm run dev or npm run build, here's what Tailwind does:
content globsclass="...")There's a subtle but important detail here: Tailwind does not parse HTML or JavaScript ASTs. It uses regex to match strings that look like class names. This means:
// Tailwind CAN detect these
<div className="bg-blue-500" />
<div className={`px-${dynamic}`} /> // dynamic concatenation → Tailwind cannot see px-4!
// Tailwind CANNOT detect this
const classes = "bg-blue-500"; // variable assignment won't be scanned
<div className={classes} />This is why the Tailwind docs repeatedly emphasize: "Don't construct class names with string concatenation." If a class name is assembled dynamically at runtime, Tailwind can't see it during the scan phase, and the corresponding CSS rule simply won't be generated.
Scanning done. Tailwind now holds the "used classes list." Next it generates CSS rules on demand based on that list.
For example, if the list contains bg-blue-500, Tailwind looks it up in its design token table:
bg-blue-500 → background-color: #3b82f6bg-red-500 not in the list? Skip it. No rule generated.
The generated rules are inserted into the PostCSS CSS stream and flow onward to downstream stations (like Autoprefixer adding -webkit- prefixes).
After Tailwind generates the CSS, PostCSS continues downstream — Autoprefixer adds browser prefixes — then css-loader converts it into a JS string. Finally, in development, style-loader turns it into <style> tags; in production, MiniCssExtractPlugin extracts it as a .css file.
What emerges is a .css file containing only the rules for classes you actually used. This is why a production Tailwind project's CSS is often just a few KB — it didn't "generate everything then delete what's unused." It never generated the unused stuff in the first place.
| Phase | Who Does It | When |
|---|---|---|
| Scan source files | Tailwind (regex matching) | Every build (dev or build) |
| Generate CSS | Tailwind (design token lookup) | Same build pass |
| Output | PostCSS pipeline | Same build pass |
The v3/PostCSS approach had three pain points:
tailwind.config.js + postcss.config.js + content path configs. Miss one, and nothing works.v4's choice: rewrite the entire core engine in Rust, becoming a standalone tool rather than a parasite on PostCSS.
This new engine is called Oxide (as in oxidation — it's faster). It does everything v3 did via PostCSS — scanning, generation, output — but 10x to 100x faster.
v3 architecture:
Tailwind v3 (JS / PostCSS plugin)
├── Regex-scan source files (JS)
├── Generate CSS from theme config (JS)
└── Output into PostCSS pipeline → CSS filev4 architecture:
Tailwind v4 (Rust standalone engine)
├── AST-parse CSS / HTML / JSX (Rust)
├── Auto-discover source files (no content config needed)
├── Generate from @theme directives in CSS (no JS config needed)
└── Output CSS directly (no PostCSS)Shift 1: From Regex to AST.
v3 used regex to match class="..." — string-level guessing. It had no idea and no interest in the semantic meaning of that string in your code. v4 uses a real AST (Abstract Syntax Tree) parser — it understands whether a piece of code is HTML, JSX, or a Vue template, and precisely extracts class names from attribute values.
This means v4's scanning isn't "dumb matching" anymore — it's semantic understanding. The false negatives and false positives inherent to regex are fundamentally eliminated.
Shift 2: Zero-config Auto-Discovery.
v3 forced you to manually tell Tailwind where to scan (content: ['./src/**/*.{js,jsx}']). Forget to configure it → classes missed → styles don't apply. This is the single most common Tailwind rookie trap.
v4 auto-discovers your source files. It understands your project structure, automatically skips node_modules and build artifacts, and scans only your source code. No more content config.
Shift 3: CSS Replaces JS for Configuration.
v3 configuration was a JavaScript file:
// tailwind.config.js (v3 style)
module.exports = {
theme: {
extend: {
colors: {
brand: '#6366f1',
},
},
},
};v4 configuration becomes a @theme directive written directly in CSS:
/* app.css (v4 style) */
@import "tailwindcss";
@theme {
--color-brand: #6366f1;
}This means Tailwind no longer needs to parse a JavaScript config file — all its configuration comes from the CSS file itself, and CSS parsing is integrated into the Rust engine. One fewer "parse JS → inject into CSS" step, a shorter build chain.
No more postcss.config.js. v4 offers three integration paths:
Path 1: Vite Plugin (most common)
// vite.config.js
import tailwindcss from '@tailwindcss/vite';
export default {
plugins: [tailwindcss()],
};When Vite processes CSS, it hands the CSS to Tailwind's Rust engine. The engine processes it and outputs the result directly, bypassing PostCSS entirely.
Path 2: Standalone CLI
npx @tailwindcss/cli -i app.css -o output.cssCompiles an input CSS file to an output CSS file. No build tool dependency at all.
Path 3: Lightning CSS Integration
Lightning CSS is another Rust-based CSS tool (for minification, vendor prefixes, etc.). Tailwind v4 registers itself as a Lightning CSS plugin. This means Tailwind's generation and CSS post-processing (minification, prefixing) happen in the same Rust process, with no JavaScript handoff in between.
Source CSS → [Lightning CSS + Tailwind v4 plugin] → Final CSS
└── all in Rust, single-pass processing| v3 (PostCSS Plugin) | v4 (Rust Standalone Engine) | |
|---|---|---|
| When does scanning happen? | Every dev/build, when PostCSS processes CSS files | Every dev/build, when Vite plugin or CLI processes CSS |
| Scanning method | Regex text matching | AST-based source parsing |
| Configuration | tailwind.config.js (JavaScript) | @theme directive (in CSS) |
| Scan targets | Manually configured content paths | Auto-discovered |
| Build tool dependency | Must go through PostCSS (requires two config files) | Optional: Vite plugin / CLI / Lightning CSS |
| Performance | JS regex scanning, can be slow on large projects | Rust engine, 10x–100x faster |
| Generated CSS | Only used classes (JIT) | Only used classes (same on-demand principle) |
Let's say you're developing with Vite + Tailwind v4 and you write this:
function App() {
return <h1 className="text-3xl font-bold text-blue-600">Hello</h1>;
}You press Ctrl+S. Here's what happens:
App.jsx changed@import "tailwindcss"text-3xl, font-bold, text-blue-600The entire process completes in tens of milliseconds. If you were on v3, steps 4–5 would execute in JavaScript and could be several times slower.
# Scaffold a Vite + React project with Tailwind v4
npm create vite@latest test-tw -- --template react
cd test-tw
npm install
npm install tailwindcss @tailwindcss/viteConfigure vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
});Add this line to the very top of src/index.css:
@import "tailwindcss";Now clear out App.jsx and write only a single button:
function App() {
return <button className="bg-green-500 px-4 py-2 rounded">Click me</button>;
}
export default App;Run npm run build and open the CSS file under dist/assets/. You'll see CSS rules only for bg-green-500, px-4, py-2, and rounded. No bg-red-500. No m-8. Nothing you didn't use.
Now add text-white to the button, rebuild, and the CSS file gains exactly one new rule: .text-white { color: #fff; }. Everything else stays the same.
In the same project, construct a class name using a variable:
const color = 'blue';
return <button className={`bg-${color}-500`}>Click me</button>;Save and check the browser — the button has no blue background. Why? Because bg-${color}-500 is a dynamically concatenated string. Tailwind's scanner sees the literal string `bg-${color}-500` — it has no idea this will resolve to bg-blue-500 at runtime, so it never generates the CSS for it.
@import / url() references in CSS and converts them into webpack modules, outputting a JS string<style> tags during development; replaced by MiniCssExtractPlugin in production, which extracts standalone .css files@theme directive: v4's CSS-native configuration method, replacing tailwind.config.js