Inside the Engine

Tailwind CSS Intermediate/Advanced

Chapter 1 · Inside Tailwind v4: Engine, Scanning and Layers

Tailwind CSS Fundamentals taught you to write the classes. This course is about running Tailwind in real projects, and almost every real-project problem (a class that does nothing, a stylesheet that is bigger than expected, old CSS that refuses to be overridden) comes down to the same question: what does Tailwind actually do between your markup and the CSS file? This chapter opens the box. You will see what the scanner reads and what it skips, how to point it at the files it misses, why class names built in code vanish, what the generated file is made of, why a single unlayered rule beats every utility, and what the final optimisation step changes.

Every example here was run
Everything below was built with the Tailwind CLI 4.3.3 (@tailwindcss/cli) on Node 24.18.0 and Windows 11, in scratch projects outside this site. Computed styles were read from headless Chrome. The CLI pulled in Lightning CSS 1.32.0 and @parcel/watcher 2.5.1 (checked with npm ls). Not tested: macOS or Linux, watching a network drive, the Vite and PostCSS plugins (chapter 5), and any Tailwind version other than 4.3.3.

The Pipeline in One Picture

Tailwind v4 does four jobs, in order. Knowing which one is misbehaving is most of the skill of debugging it:

StepWhat happensWhere it goes wrong
1 · ScanA scanner (the Rust package @tailwindcss/oxide) reads your project files as plain text and pulls out every word that might be a class nameThe file is never read (ignored, or in a folder it does not know about)
2 · MatchThe compiler checks each candidate against the utilities and variants it knows, plus your theme. Real classes become rules; everything else is dropped silentlyThe class is not written out in full, or is misspelt (no error, just no rule)
3 · ArrangeThe rules are placed in cascade layers, and the theme variables that were used are written outOther CSS is outside the layers and wins
4 · TransformLightning CSS (a Rust CSS tool) post-processes the result; with --optimize or --minify it also rewrites and compresses itSurprise differences between dev and production output

The most important consequence is in step 1. The scanner does not parse JavaScript, HTML or Markdown; it only looks for words. It cannot tell a class attribute from a sentence. I proved that with a Markdown file that contains no markup at all:

We need to flex a little and keep the block hidden from view; the table is a fixed size.
.fixed .block .flex .hidden .table (the five rules generated from that one sentence)

Five utilities were generated from a sentence. That is harmless, apart from a few extra bytes, but it explains two things you will see later: classes in comments and strings count, and a class name assembled from parts is invisible, because the finished word never appears in any file.

What the Scanner Reads, and What It Skips

By default v4 needs no content list. It scans the project from the current directory and skips what is clearly not source. To see exactly where the line falls, I put one distinct colour class in each kind of file, built, and checked which colours came out:

File (and the class inside it)Used?Why
src/index.html (bg-red-500)yesan ordinary source file
src/page.md (bg-orange-500)yesany text file counts, not just HTML and JS
docs/notes.txt (bg-teal-500)yessame
dist/built.html (bg-yellow-500)nodist/ is listed in .gitignore
private/secret.html (bg-green-500)noalso in .gitignore
node_modules/pkg/index.js (bg-lime-500)nonode_modules is always skipped
src/styles.css (.bg-cyan-500 {})noCSS files are never scanned
assets/logo.png (text bg-sky-500 inside)nobinary file types are skipped
package-lock.json (bg-blue-500)nolock files are skipped

One detail surprised me, so I tested it twice: the .gitignore is honoured even when the folder is not a git repository. I ran the build before and after git init and the answer was identical (red, orange and teal only). The consequence is practical: a class that lives only in a generated or ignored folder will quietly not exist.

Telling the Scanner Where to Look

Three tools in your CSS file change what is scanned. All of them were run against the same nine files above, and the colours that appeared are in the last column.

DirectiveWhat it doesColours that appeared
@source "./node_modules/pkg";
@source "./private";
@source "./dist";
Adds a folder to the automatic scan. It overrides .gitignore and the node_modules rule for that folderred orange teal + lime, green, yellow
@source not "./src/page.md";
@source not "./docs";
Removes a file or folder from the scanred only
@import "tailwindcss" source(none);
@source "./src";
Turns the automatic scan off and lists the folders yourselfred orange (the two scannable files in src/; styles.css still ignored)
@import "tailwindcss" source("./docs");Moves the base directory of the automatic scan to ./docsteal only
@source "./src/*.html";A glob: only matching filesred only

Two more rules from my runs. First, paths in @source are relative to the CSS file that contains them: with the CSS file in css/, I needed @source "../src" and it found red and orange. Second, naming a folder does not make Tailwind read file types it never reads: even with @source "./src", the class in src/styles.css never appeared.

When to use which
Leave the automatic scan on for ordinary apps. Add @source for a component library installed in node_modules that ships Tailwind classes, or for a sibling package in a monorepo. Use source(none) only when you want full control (a shared theme package that scans nothing of its own). @source not is for generated folders that are not in .gitignore, or noisy content such as a CMS export full of prose.

Classes the Scanner Cannot See

This is the failure that costs the most time. Here is a badge function that builds its colour from an argument, and the check I ran on the output:

export const badge = (color) => `rounded px-2 py-1 text-white bg-${color}-600`; badge("red"); // renders: bg-red-600
bg-red-600 / bg-green-600 / bg-blue-600 present in the CSS: none utility rules generated: 4 (rounded, px-2, py-1, text-white)

The browser receives bg-red-600, but the scanner only ever sees the file, where the text is bg-${color}-600, and that is not a class. The same goes for "text-" + "red-600". Nothing warns you. There are three fixes, in order of preference:

PatternHowResult in my run
Write the full name somewhere (best)A lookup table: { red: "bg-red-600", green: "bg-green-600", blue: "bg-blue-600" }[color]all three colours present (7 rules)
Declare the set in CSS@source inline("bg-{red,green,blue}-600"); in your CSSall three present (7 rules)
Do not use dynamic names at allPass the whole class as data (from your CMS, config or a prop), so it appears in a file the scanner readsdepends on where the data lives

@source inline() is the v4 replacement for the old safelist option, and it understands brace expansion, number ranges and variants. This single line, built and listed, produced all of the classes below:

@import "tailwindcss" source(none); @source inline("underline"); @source inline("{hover:,focus:,}bg-red-{50,{100..900..100},950}"); @source inline("lg:text-{sm,lg}"); @source not inline("bg-red-{100,200}");
plain: .underline .bg-red-50 .bg-red-300 … .bg-red-900 .bg-red-950 (bg-red-100 and bg-red-200 are gone) focus: .focus:bg-red-50 … .focus:bg-red-950 (11 rules, including 100 and 200) hover: .hover:bg-red-50 … .hover:bg-red-950 (inside @media (hover: hover), 11 rules) lg: .lg:text-sm .lg:text-lg (inside @media (width >= 64rem))

Look at the last line of the input. @source not inline("bg-red-{100,200}") removed exactly the two plain classes it named, and the hover: and focus: versions of the same colours stayed. It matches whole class names, so to remove the variants you must list them as well. The braces generate every combination, so keep the sets small: @source inline("{hover:,focus:,}bg-red-{50,{100..900..100},950}") is 33 classes from one line, and a careless range can add thousands.

Reading the Output

A small page (one button with six classes) builds to 188 lines and 5,191 bytes. Almost all of that is the same every time. The structure is what to read:

/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ @layer theme, base, components, utilities; @layer theme { :root, :host { --font-sans: …; --color-sky-600: oklch(58.8% 0.158 241.966); --spacing: 0.25rem; … } } @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; … } … } @layer utilities { .rounded-lg { border-radius: var(--radius-lg); } .bg-sky-600 { background-color: var(--color-sky-600); } .px-4 { padding-inline: calc(var(--spacing) * 4); } … @media (hover: hover) { .hover\:bg-sky-700:hover { background-color: var(--color-sky-700); } } }
PartContentsNotes
@layer theme, base, components, utilities;The order of the four layers, declared once, firstLater layers win over earlier ones
themeYour design tokens as CSS variables on :rootOnly the ones in use (see below)
baseThe reset (Preflight): borders, margins, form controls, headingsSame on every build
componentsEmpty unless you write rules into itWhere plugins and your own component classes go
utilitiesOne rule per class found, in a fixed order, with variants as media queries or selectorsThe part that grows with your page

Theme variables are written only when something uses them. I defined three tokens and used one:

@theme { --color-brand: #0af; --color-unused: #f0a; } @theme static { --color-always: #0fa; }
With <div class="bg-brand"> in the page: --color-brand: #0af; --color-always: #0fa; (--color-unused is not in the file) Without any use of bg-brand: --color-always: #0fa; (only the static one)

Plain @theme keeps your file small; @theme static (or the static option) forces a variable out even when nothing uses it, which you need when other CSS or JavaScript reads it with var(--color-always) or getComputedStyle. Chapter 2 (Tailwind CSS Intermediate/Advanced 2) builds a whole token system on this.

Layers and the Cascade: Why Plain CSS Beats Utilities

Cascade layers change the order in which rules compete. The relevant fact, which is easy to miss because layers are new to many developers: CSS that is not inside any layer beats CSS that is inside a layer, whatever the specificity. Tailwind puts every utility inside @layer utilities, so any ordinary rule you write at the top level of a stylesheet outranks them. I tested four placements of an identical font-size: 10px rule against text-xl (20px) on the same elements, and read the result from Chrome:

.card1 { font-size: 10px; } /* no layer */ @layer components { .card2 { font-size: 10px; } } @layer utilities { .card3 { font-size: 10px; } } /* written after text-xl */ @layer base { .card4 { font-size: 10px; } } <p class="text-xl card1"> … class="text-xl card2" … class="text-xl card3" … class="text-xl card4"
card1 (unlayered) font-size 10px the plain rule wins card2 (components layer) font-size 20px text-xl wins: utilities come after components card3 (utilities layer, later) font-size 10px same layer, same specificity, so the later rule wins card4 (base layer) font-size 20px text-xl wins

That gives the working rules for any stylesheet in a Tailwind project:

  • A rule in @layer components can be overridden by a utility on the element, which is the behaviour you want from a component class. It is why Tailwind provides that layer.
  • An unlayered rule beats a utility. This is the usual explanation when "my p-8 does nothing" on an element that an older stylesheet also styles.
  • Inside one layer, the normal rules apply: higher specificity wins, then the later rule.

The clean fix for old CSS is not to raise specificity or add !important, but to put the old stylesheet into a layer. A legacy rule .btn { padding: 4px; background-color: gray; } used on class="btn p-8 bg-blue-600":

@import "tailwindcss" source(none); @source "./src"; @import "./src/legacy.css"; /* version A: unlayered */ @import "./src/legacy.css" layer(components); /* version B: into the components layer */
A: padding 4px background rgb(128, 128, 128) the legacy rule beats p-8 and bg-blue-600 B: padding 32px background oklch(0.546 0.245 262.881) the utilities win
A note on !important
Tailwind has an important modifier (a trailing !, as in p-8!) for one-off emergencies. It works (not run here), but it is a patch. Layering the old CSS fixes the cause, once, for every rule in it.

What Lightning CSS Does

The last step runs your CSS through Lightning CSS. The default build leaves most of your own CSS alone; --optimize and --minify rewrite it. I gave the CLI a rule that uses nesting, a prefixed property and color-mix(), and built it three ways:

.card { user-select: none; background: color-mix(in srgb, red 40%, blue); &:hover { color: red; } .title { font-weight: 700; } }
default 5,524 bytes kept exactly as written (nesting, color-mix, no prefix) --optimize 6,238 bytes .card { -webkit-user-select: none; user-select: none; background: #609; } .card:hover { color: red; } .card .title { font-weight: 700; } --minify 4,881 bytes …}.card{-webkit-user-select:none;user-select:none;background:#609}.card:hover{color:red}.card .title{font-weight:700}

Three observations from that run, each worth knowing before you debug a production-only difference:

  • The nesting was flattened (to .card:hover and .card .title), the vendor prefix was added (-webkit-user-select), and color-mix() with two fixed colours was computed (#609). The tool lowers your CSS for the browsers it targets.
  • --optimize was bigger than the default build, not smaller. I expected the opposite. Flattening and splitting grouped selectors into separate rules adds text; only --minify shrinks the file, and it also does the optimising.
  • The layer-order line disappears in optimised output (@layer theme, base, components, utilities; is gone), but the order is kept: the layers still appear in sequence, with an empty @layer components; placeholder holding the third position.

So the rule is: compare like with like. If a bug shows only in production, build the development CSS with --minify locally and test that.

Watch Mode

--watch keeps the compiler running and rebuilds when a scanned file changes. I started it on a project with one file, then edited a class, then added a new file. The file lists after each step:

npx @tailwindcss/cli -i tw.css -o wout.css --watch=always (1) start → .p-1 (2) edit index.html: add underline → .p-1 .underline (3) create new.html with p-9 → .p-1 .p-9 .underline
≈ tailwindcss v4.3.3 Done in 466ms the first build Done in 10ms Done in 77ms Done in 10ms Done in 52ms five builds in all: one at the start, then the ones triggered by my edit and the new file

New files are picked up without restarting. Plain --watch stops when standard input closes (which happens under some task runners and CI jobs); --watch=always keeps going. The option list also has --poll for file systems that do not report changes. I did not test polling.

Mistakes while preparing this chapter
Wrong assumption 1: "vendor code is skipped." My first test project put a library in vendor/ and expected @source to be needed for it. It was scanned by default; only node_modules, ignored folders, binary files, CSS and lock files are skipped. I moved the library into node_modules to make the exercise honest. How to avoid it: build with no directives first and look at what is already there before adding any.

Wrong assumption 2: ".gitignore needs a git repository." It does not; the ignore file is read anyway (tested before and after git init). How to avoid it: test the thing you remember, not the thing you assume.

Wrong assumption 3: "--optimize makes the file smaller." It made it 13% bigger (6,238 against 5,524 bytes). How to avoid it: measure with the flag you actually ship.

Hands-On Exercises

Exercise 1

Create a project with six files, each holding a different colour class: an HTML file in src/, a Markdown post in content/, a script in node_modules/ui-kit/, an HTML file in a dist/ folder that your .gitignore lists, a CSS file and a PNG. Predict which of six colours will be generated, then build and check. Then make the node_modules script count, remove the Markdown post, and finally switch to source(none) with two folders listed.

📄 View solution
Exercise 2

Write a badge(color) function that builds bg-${color}-600. Prove, from the generated CSS, that none of the three colours exist. Fix it two ways (a lookup table and @source inline()) and compare the number of rules generated each time.

📄 View solution
Exercise 3

An old stylesheet defines .btn { padding: 4px; background-color: gray; }. Use it together with p-8 bg-blue-600 on one button and read the computed padding and background in Chrome. Then fix it by importing the old file into the components layer, and read the computed values again. Explain each result in terms of layers.

📄 View solution

Chapter 1 Quick Reference

  • Pipeline: scan (plain text, Rust) → match against known utilities → arrange in layers → Lightning CSS. Unknown words are dropped silently, and prose words such as flex, block and table become real rules
  • Skipped by default: .gitignored paths (even without git), node_modules, binary files, CSS files, lock files. Scanned: any other text file, including .md and .txt
  • @source "./path" adds (and overrides ignore rules); @source not "./path" removes; paths are relative to the CSS file; globs work
  • @import "tailwindcss" source(none) turns the automatic scan off; source("./dir") moves its base directory
  • Dynamic names such as bg-${color}-600 never appear in any file, so they generate nothing. Fix: a lookup table of full names, or @source inline("bg-{red,green,blue}-600")
  • @source inline() supports braces, ranges ({100..900..100}) and variants; @source not inline() removes only the exact names you list
  • Output order: @layer theme, base, components, utilities;; theme variables appear only when used (@theme static forces them)
  • Unlayered CSS beats every layered rule; @layer components loses to utilities; import old CSS with layer(components)
  • --optimize flattens nesting, adds prefixes and folds constants but can be larger; --minify is the one that shrinks. Test production CSS with --minify
  • --watch rebuilds in tens of milliseconds and sees new files; --watch=always survives closed stdin
Coming next
Chapter 2 builds on the theme variables you just saw written to the output: semantic design tokens (--color-surface, --color-primary), @theme inline, switching between brand and light/dark themes with a data-theme attribute, and modern colour with oklch and color-mix(). Start with Tailwind CSS Intermediate/Advanced 2.