Tailwind v4 scans every file under the build’s working directory, and any string in them that happens to look like a utility name becomes real CSS — no backticks, no class attribute, no intent. If your output contains classes nothing uses, or a docs-only edit changed your bundle size, this is why.
This is not a README problem
The obvious rule — don’t write class names into scanned files — is far too narrow. The hazard is any scanned string: test descriptions, story titles, fixture data, comments, prose. From a real package:
// tests/theme.test.ts:99it('keeps ring utilities, which carry visible focus states', () => {
That test name put .ring and .visible into the published stylesheet. Two phantom selectors from one sentence that nobody would ever think to audit.
Detect it
Signature: a change that touches no code moves the stylesheet’s byte count. Nothing else in a CSS build does that.
Two builds, diff the selector sets. Build normally, build again with source(none), compare:
Or list un-hyphenated one-word selectors and ask which of them any code uses. Utilities are overwhelmingly hyphenated (mt-3, text-sm); a bare single word is the shape English produces:
source(none) disables automatic detection entirely; the explicit @source directives then name only real code directories. This removes every scanning-derived selector at once, and requires no authoring discipline from anyone writing tests or docs.
Safelist what the vocabulary must guarantee
If a published class list is a promise (a design system’s vocabulary, classes a CMS emits, anything assembled at runtime), put it in @source inline(...). Do not leave it to scanning — scanning happens to work until someone rewords a sentence.
What actually counts as a source
Three rules, and they do not agree with each other:
1. The scan base is the build’s working directory — not the git root, and not the CSS file’s directory. Verified with one styles.css in a git repo at mono/, built twice:
build run from
docs/root-doc.md at repo root
packages/ui/**
packages/ui
not scanned
scanned
repo root
scanned
scanned
The same input file produced 16367 vs 16456 bytes purely from cd. Since pnpm runs a package’s build:css inside that package, repo-root docs/ is genuinely outside the build — but that is a property of how you invoke it, not a guarantee.
2. @source adds to automatic detection, it does not replace it. Writing @source "./components" widens the scan; it does not narrow it. Only source(none) narrows.
3. @source paths resolve relative to the CSS file, not the working directory. So @source "./components" inside src/styles.css means src/components — a different base from the one rule 1 uses.
Why choosing your words cannot save you
The extractor takes anything token-shaped. Whether a word survives depends on the punctuation that follows it — not something anyone can track while writing a sentence.
Verified on Tailwind v4.3.3 by appending one line to a scanned file:
the word is followed by…
candidate
result
a space — the box is visible now
visible
.visible generated
end of line — the box is visible
visible
.visible generated
. — the box is visible.
visible.
nothing — invalid candidate
, — the box is visible, mostly
visible,
nothing
; — the box is visible; ok
visible;
nothing
) — the box is (visible)
(visible)
nothing
So a word ending a sentence is harmless and the same word mid-sentence is not. That is why the damage looks arbitrary, and why reading a file tells you nothing. Two real lines from one README:
an oxide-red 24px grid. → inert, trailing period
The grid pitch is 24px → generates .grid
Backticked class names in documentation are harvested by the same mechanism — `p-6`, `gap-6`, `py-12`, `font-hand!` all became selectors. Code formatting is not a fence; it is just more text.
What it cost
Reproduced directly. Appending this one sentence to a scanned file:
The output will shrink once source scanning is scoped, and the @source path is relative to the file it sits in.
78 bytes of CSS generated by English. Neither word was written as a class; both were followed by a space.
Across the whole package (Tailwind v4.3.3, built from the package directory), source(none) took the output from 73 selectors / 16309 bytes to 48 / 13988 — a 14% cut. The 25 removed selectors came from:
Storybook stories (src/stories/) — .flex, .gap-3, .h-10, .mt-9, .uppercase, .w-24 and a dozen more. Dev-only files that ship nothing, styling the published stylesheet.
Tests (tests/) — .ring and .visible from a test name; .mb-6 from a fixture’s className.
README — .grid from prose, .gap-6 / .py-12 / .font-hand! from documented examples.
For Agents
Two traps if you try to fix this by editing text:
A word can occur more than once..relative appeared twice in one file; removing one instance changed nothing, which reads exactly like the fix failing. Count occurrences before concluding an edit had no effect.
Do not fix it by rewording. Chasing utility names out of prose is unmaintainable, silently regresses on the next edit, and cannot reach test names or fixture data at all. source(none) is the only fix that holds.
Why this matters beyond tidiness
In a design system the shipped stylesheet is a contract. Selectors that exist only because of how a sentence was phrased mean the artifact depends on prose wording — reword the sentence and CSS disappears from the bundle. A README asserting “this stylesheet contains X and nothing more” can be made false by its own text. And because the scan base is the working directory, the same source can build to two different stylesheets depending on where the command ran.
One edge worth remembering: the sentence that created .shrink was itself a sentence about phantom selectors, written while fixing this class of bug. Awareness of the trap does not protect you from it — which is the whole argument for detecting it mechanically rather than authoring carefully.
Not yet applied in muetal/design-system
As of 2026-08-31, packages/ui/src/styles.css still begins @import "tailwindcss"; with no source(none) — scoping is deferred to phase 2. The prescription above is verified against that tree but has not landed in it.
Related
muetal — the design system where this surfaced; its shipped vocabulary is exactly the kind of contract this breaks
bsd-sed-unicode-escape-gotcha — sibling class of bug: tooling silently producing the wrong bytes, where the output looks correct on screen