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:99
it('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:

sel() { grep -oE '^\s*\.[A-Za-z0-9_\\:.,!-]+ \{' "$1" | sed 's/ {//; s/^ *//' | sort -u; }
sel dist/styles.css > /tmp/a.txt
# rebuild with source(none), then:
sel dist/styles.css > /tmp/b.txt
comm -23 /tmp/a.txt /tmp/b.txt      # everything scanning invented

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:

grep -oE '^\s*\.[a-z][a-z0-9]* \{' dist/styles.css | sort -u

flex, grid, border, visible, block, fixed, static, hidden, table, content, order, sticky, shrink, relative, ring, truncate, italic, underline are all simultaneously ordinary English and Tailwind utilities.

Fix it

@import "tailwindcss" source(none);
 
@source "./components";
@source "./lib";
 
@source inline("{bg,text,border}-{paper,ink,oxide}");

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 fromdocs/root-doc.md at repo rootpackages/ui/**
packages/uinot scannedscanned
repo rootscannedscanned

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…candidateresult
a space — the box is visible nowvisible.visible generated
end of line — the box is visiblevisible.visible generated
.the box is visible.visible.nothing — invalid candidate
,the box is visible, mostlyvisible,nothing
;the box is visible; okvisible;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 24pxgenerates .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.

produced exactly:

.relative { position: relative; }
.shrink   { flex-shrink: 1; }

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.

  • 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
  • nbsp-testing-library-normalizer — another invisible-input failure from the same project