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.
@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:
| Step | What happens | Where it goes wrong |
|---|---|---|
| 1 · Scan | A scanner (the Rust package @tailwindcss/oxide) reads your project files as plain text and pulls out every word that might be a class name | The file is never read (ignored, or in a folder it does not know about) |
| 2 · Match | The compiler checks each candidate against the utilities and variants it knows, plus your theme. Real classes become rules; everything else is dropped silently | The class is not written out in full, or is misspelt (no error, just no rule) |
| 3 · Arrange | The rules are placed in cascade layers, and the theme variables that were used are written out | Other CSS is outside the layers and wins |
| 4 · Transform | Lightning CSS (a Rust CSS tool) post-processes the result; with --optimize or --minify it also rewrites and compresses it | Surprise 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:
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) | yes | an ordinary source file |
src/page.md (bg-orange-500) | yes | any text file counts, not just HTML and JS |
docs/notes.txt (bg-teal-500) | yes | same |
dist/built.html (bg-yellow-500) | no | dist/ is listed in .gitignore |
private/secret.html (bg-green-500) | no | also in .gitignore |
node_modules/pkg/index.js (bg-lime-500) | no | node_modules is always skipped |
src/styles.css (.bg-cyan-500 {}) | no | CSS files are never scanned |
assets/logo.png (text bg-sky-500 inside) | no | binary file types are skipped |
package-lock.json (bg-blue-500) | no | lock 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.
| Directive | What it does | Colours 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 folder | red orange teal + lime, green, yellow |
@source not "./src/page.md";@source not "./docs"; | Removes a file or folder from the scan | red only |
@import "tailwindcss" source(none);@source "./src"; | Turns the automatic scan off and lists the folders yourself | red orange (the two scannable files in src/; styles.css still ignored) |
@import "tailwindcss" source("./docs"); | Moves the base directory of the automatic scan to ./docs | teal only |
@source "./src/*.html"; | A glob: only matching files | red 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.
@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:
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:
| Pattern | How | Result 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 CSS | all three present (7 rules) |
| Do not use dynamic names at all | Pass the whole class as data (from your CMS, config or a prop), so it appears in a file the scanner reads | depends 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:
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:
| Part | Contents | Notes |
|---|---|---|
@layer theme, base, components, utilities; | The order of the four layers, declared once, first | Later layers win over earlier ones |
theme | Your design tokens as CSS variables on :root | Only the ones in use (see below) |
base | The reset (Preflight): borders, margins, form controls, headings | Same on every build |
components | Empty unless you write rules into it | Where plugins and your own component classes go |
utilities | One rule per class found, in a fixed order, with variants as media queries or selectors | The part that grows with your page |
Theme variables are written only when something uses them. I defined three tokens and used 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:
That gives the working rules for any stylesheet in a Tailwind project:
- A rule in
@layer componentscan 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-8does 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":
!, 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:
Three observations from that run, each worth knowing before you debug a production-only difference:
- The nesting was flattened (to
.card:hoverand.card .title), the vendor prefix was added (-webkit-user-select), andcolor-mix()with two fixed colours was computed (#609). The tool lowers your CSS for the browsers it targets. --optimizewas bigger than the default build, not smaller. I expected the opposite. Flattening and splitting grouped selectors into separate rules adds text; only--minifyshrinks 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:
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.
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
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.
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.
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.
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,blockandtablebecome real rules - Skipped by default:
.gitignored paths (even without git),node_modules, binary files, CSS files, lock files. Scanned: any other text file, including.mdand.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}-600never 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 staticforces them) - Unlayered CSS beats every layered rule;
@layer componentsloses to utilities; import old CSS withlayer(components) --optimizeflattens nesting, adds prefixes and folds constants but can be larger;--minifyis the one that shrinks. Test production CSS with--minify--watchrebuilds in tens of milliseconds and sees new files;--watch=alwayssurvives closed stdin
--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.