Tailwind ships an upgrade tool that handles the mechanical majority of a v4 migration. The remaining 20% — config-file logic, dynamic class construction, plugin replacements — is judgment work an LLM accelerates. The discipline is keeping the two lanes separate and gating every step behind pixel diffs, because style migrations fail silently.
Tier: Core · Workflow area: Styling, responsive & i18n
A migration PR that "looks fine" is the most dangerous artifact in frontend work: nothing crashes, nothing typechecks red, and the buttons are 2px smaller everywhere. This playbook builds the safety net first, then splits the work by what each tool is actually good at.
@theme config, renamed utilities, new import.tailwind.config.js carries custom themes, plugins, or JS logic that has no mechanical translation.clsx, template strings, CVA variants) where codemods fear to tread.Build the pixel gate before touching anything. Capture the "before" truth on the unmodified branch:
npx playwright screenshot --viewport-size=390,844 http://localhost:3000/ shots/before/home-mobile.png
In practice, write a small script iterating your route list at two or three viewports — or use Playwright's toHaveScreenshot() against a baseline run. Fifteen representative screens beat two perfect ones. Commit the baselines.
Run the official upgrade tool in its own commit. The mechanical 80%:
npx @tailwindcss/upgrade
It migrates @tailwind directives to @import "tailwindcss", renames deprecated utilities (shadow-sm → shadow-xs, outline-none → outline-hidden, and friends), updates PostCSS/Vite wiring, and converts what it can of your config to CSS @theme. Commit the result untouched, labeled chore: tailwind v4 codemod (mechanical). Never mix hand edits into this commit — you want a pure diff you can trust and, if needed, revert.
Screenshot-diff the codemod commit. Re-run the capture into shots/after-codemod/ and diff. Most diffs here will be genuinely intentional v4 rendering differences (default border color, ring width changes) — but each one gets classified, not waved through. Fix or accept, and record the accepted ones in the PR description.
Hand the LLM the residue, with the diff as context. Now the 20%. Feed the agent three things: what the tool left behind (it prints warnings for what it could not migrate), your old tailwind.config.js, and the new CSS file:
The Tailwind v4 upgrade tool ran; commit attached. Remaining work:
1. tailwind.config.js still holds a plugin (typography), a custom
spacing scale, and theme.extend colors referencing CSS vars.
Convert to @theme in app.css. Show me the mapping table
(old config key -> new @theme variable) before editing.
2. Grep for dynamically built class names (clsx, cva, template
literals) that reference renamed utilities — list every hit with
file:line. Do NOT bulk-edit these; propose per-site fixes.
Anything ambiguous, ask; do not guess a value.
Dynamic class sites are the classic silent killer — shadow-${size} never matches a codemod's grep, and never crashes at runtime. The model is good at finding them; you decide the fix per site.
Review the LLM lane like a design change, not a refactor. For each config translation, check the mapping table against rendered output, not just against the old config — theme.extend semantics and v4 defaults differ in places (border and ring colors are the notorious ones). Re-run the screenshot capture after this lane and diff again. This second gate catches what reading the diff cannot.
Ship in slices if the app is big. v4's @import and v3 setups cannot coexist in one bundle, but you can still slice the review: land the codemod commit and LLM commits as separate PRs in quick succession, each with its own diff report, rather than one 400-file review nobody can hold in their head.
The upgrade tool requires a clean git state and a supported Node version, and v4 drops support for older browsers. Confirm your browser-support contract allows v4 before step 1 — discovering this in review means unwinding the whole branch.
@tailwind directives returns empty.Codemods are trustworthy and dumb; LLMs are clever and unreliable; screenshots are the only judge that cannot be fooled by either. Keep the three roles separate and a style migration becomes boring — which is exactly what you want a migration to be.