Migrating to v4
Tailwind CSS Intermediate/Advanced
Chapter 8 · Migrating from v3 to v4
Many real Tailwind projects are still on v3. The official upgrade tool does most of the mechanical work, but "most" is the problem: what it leaves behind builds without errors and looks slightly wrong. This chapter migrates a small v3 project with the real tool, measures exactly what is different afterwards (computed styles and a screenshot comparison, from chapter 7), finishes the job by hand until the page is pixel-identical, and lists the renamed, removed and changed utilities with results from the v4 build, not just from the documentation.
v3-lts tag on npm) with @tailwindcss/typography 0.5.20, PostCSS 8.5.29 and autoprefixer 10.6.1. Upgraded with @tailwindcss/upgrade to Tailwind
4.3.3, on Node 24.18.0 and Windows 11, checked in headless Chrome 154. Product claims about the upgrade (browser support, the list of removed utilities, changed defaults) are from Tailwind's upgrade guide, fetched today, and I tested
each one I could. Not run: a large real project, the Vite and Next.js plugin switch (chapter 5 covers those packages), projects using prefix, important or corePlugins in the
config, other plugins, and any browser except Chrome.
Decide First: Should You Migrate Now?
The upgrade guide says v4 is "designed for Safari 16.4+, Chrome 111+, and Firefox 128+" and that "if you need to support older browsers, stick with v3.4 until your browser support requirements change." That is the one real blocker (chapter 7). The other question is time. The endoflife.date listing (a third-party tracker, not Tailwind's own announcement, so confirm it) gives the 3.4 line an end-of-life of 2027-02-28; today is 2026-10-12, so that is about four and a half months away. The same listing shows 3.4.19 (released 2025-12-10) as the last 3.4 release. Staying on v3 is a choice with a visible end date.
The Project to Migrate
I wrote a deliberately awkward v3 project: a JavaScript config with extended colours, font, screen and radius, a safelist, class-based dark mode and the typography plugin; CSS with @tailwind directives, @apply,
@layer components and the theme() function; and a page that uses classes whose meaning changed in v4.
Run the Upgrade Tool
The upgrade tool is run with npx @tailwindcss/upgrade. Tailwind's guide says it needs Node 20 or higher and recommends a new branch. It also checks git: on a copy of the project with one uncommitted edit it refused:
On the committed project it ran for about 25 seconds and reported:
The tool changed five files (index.html, package.json, package-lock.json, postcss.config.js, src/input.css) and did not convert the JavaScript config; it
told me so, without saying why, and loaded it with @config instead. The changes that matter:
Look at the order of the renames: shadow became shadow-sm and shadow-sm became shadow-xs. The names shifted down one step, so a careless find-and-replace
would turn every old shadow-sm into the new, larger shadow-sm. The tool handles that ordering; a hand edit must too.
What the Tool Left Wrong
I built the upgraded project (it built: exit code 0, no warnings) and compared it with the v3 baseline in Chrome, once by computed styles and once by screenshot (pixelmatch, threshold 0.1):
Three of those are real regressions the tool did not (or could not) fix, and none produced a message:
bg-opacity-50 bg-blackwas left in the markup. In v4 there is no rule forbg-opacity-*(I built it: no output), so the faded button became solid black. The replacement isbg-black/50.- The ring. v3's
ringwas 3px of 50% blue;ring-3keeps the width but the default colour is nowcurrentcolor. The guide says to add a colour where you relied on the default:ring-3 ring-blue-500/50. - The panel. The tool rewrote
theme('colors.surface')tovar(--color-surface), but the config was still JavaScript and, as far as I could tell, no such CSS variable existed, so the background vanished and the radius became 0. This follows from the config not being migrated, which the tool had reported.
Finishing the Job by Hand
The remaining work is the part the tool declined: moving the JavaScript config into CSS. Each setting has a v4 home:
In tailwind.config.js (v3) | In CSS (v4) |
|---|---|
theme.extend.colors.brand[500] | @theme { --color-brand-500: #4f46e5; } |
fontFamily.display | --font-display: Georgia, serif; |
screens['3xl'] | --breakpoint-3xl: 1920px; |
borderRadius.card | --radius-card: 0.75rem; |
darkMode: 'class' | @custom-variant dark (&:is(.dark *)); |
plugins: [require('@tailwindcss/typography')] | @plugin '@tailwindcss/typography'; |
safelist: [...] | @source inline("bg-{emerald,pink}-500"); (the config's safelist is ignored, see below) |
content: [...] | automatic detection, plus @source if needed (chapter 1) |
theme('colors.x') in CSS | var(--color-x); spacing: --spacing(4) |
I deleted tailwind.config.js, wrote those into input.css, and fixed the three markup problems:
The safelist trap
While testing, bg-pink-500 (safelisted but never used in the markup) did appear in the v4 output with @config still loading the file, which looked like proof that safelist still works. It was
not. The scanner reads every file it can see, including tailwind.config.js, and found the literal text 'bg-pink-500'. When I wrote the entry as 'bg-pink-' + '500' the v3 build still
generated the class and the v4 build did not. Tailwind's guide agrees: "corePlugins, safelist, and separator … are not supported in v4.0." Use @source inline() (chapter 1).
Renamed, Removed and Changed: Checked Against 4.3.3
I built a page containing both old and new names and printed the generated rule for each, so the table shows behaviour, not just the guide's list:
| Class | v4.3.3 result | Meaning |
|---|---|---|
bg-opacity-50, text-opacity-50 | no rule | removed; use bg-black/50. Silent: the element just loses the effect |
shadow / shadow-sm / shadow-xs | shadow and shadow-sm are the same rule (1px 3px, the old shadow); shadow-xs is the old shadow-sm | old shadow-sm silently gets bigger if you do not rename it |
rounded / rounded-sm / rounded-xs | rounded = 0.25rem; rounded-sm = var(--radius-sm); rounded-xs exists | the same one-step shift as shadows |
blur / blur-sm / blur-xs | all three exist | shifted like shadows |
ring, ring-3 | ring is 1px currentcolor; ring-3 is 3px currentcolor | width 3px to 1px, colour blue-500 to currentcolor |
outline-none, outline-hidden | outline-none: outline-style: none; outline-hidden adds a forced-colors fallback | use outline-hidden to keep the v3 meaning |
flex-grow, flex-shrink-0, overflow-ellipsis, decoration-slice | still produced a rule in 4.3.3 | the guide lists them as removed in 4.0; in 4.3.3 they still built. I do not know if that is intended, so rename them (grow, shrink-0, text-ellipsis, box-decoration-slice) as the tool did |
bg-gradient-to-r / bg-linear-to-r | both exist | the tool renames to bg-linear-to-r |
!flex / flex! | both produce display: flex !important | new position is the end; the old one still works |
bg-[--brand] / bg-(--brand) | background-color: --brand; (invalid) / var(--brand) | old variable shorthand builds but is broken CSS; use (--brand) |
grid-cols-[1fr,2fr] / grid-cols-[1fr_2fr] | grid-template-columns: 1fr,2fr (invalid) / 1fr 2fr | commas are no longer turned into spaces |
The guide also lists defaults that changed without any class being renamed. Those I observed: border colour (currentcolor instead of gray-200, restored by the tool's compatibility block), ring (above), and the
outline. Those I took from the guide and did not test: placeholder colour (now the text colour at 50%), buttons using cursor: default (restore pointer with a base rule), hover: applying only where the
device supports hover, dialog margins, the hidden attribute now winning over flex, variant stacking applying left to right (first:*:pt-0 becomes *:first:pt-0), the removal
of the container center/padding options, and prefix syntax (tw:flex). Search your project for each of them before calling the migration done.
Traps That Build Without Complaint
The first is the one to remember: leaving the v3 directives in place does not fail, it produces an empty stylesheet. After any migration, build and check that one known class has a rule (chapter 6's rule again). An
@apply of a removed utility, by contrast, fails loudly. The theme() function is still understood, although the guide recommends variables.
Running Both Versions Side by Side
During a gradual migration you may need v3 for one folder and v4 for another. npm can install the same package under two names. I installed both in one folder and built the same markup with each:
Both ran from one package.json and the same class name produced different shadows, which is exactly why the two should not process the same files. Give each its own input CSS and its own set of
templates. I did not run a framework build with two versions at once.
A Migration Checklist
- Decide: browser targets (Chrome 111, Safari 16.4, Firefox 128) and the 3.4 end-of-life date.
- Commit everything; make a branch. Take a baseline: screenshots and a few computed styles of pages that matter.
- Run
npx @tailwindcss/upgrade(Node 20+). Read its output, including what it says it could not do. - Build and check the output has rules for known classes (an empty stylesheet exits 0).
- Move the JavaScript config into CSS:
@theme,@plugin,@custom-variant,@source inline(). Delete the config and the@configline. - Search for removed and shifted classes:
*-opacity-*,ring,outline-none,shadow-*,rounded-*,blur-*,[--var], commas ingrid-cols-[...]. - Compare screenshots against the baseline; look at every difference.
- Remove the compatibility border-colour block only after adding explicit border colours where needed.
surface: 'theme(colors.slate.50)' in the config; v3 did not resolve it and the panel was transparent. I only saw it in the first probe output. How to avoid
it: read the baseline results before starting the migration; a migration cannot be compared with a baseline that is itself broken.2. I thought
safelist still worked under @config. The class appeared only because the scanner read the config file's text. How to avoid it: test a feature with input the scanner cannot see (a concatenated string), not the same literal it can.3. My comparison script broke on renamed classes and compared the wrong property. It selected
p.overflow-ellipsis, which no longer existed, and it read margin-left for space-x, which v4 moved to margin-right on the other element. How
to avoid it: select by something that survives the change and compare what the user sees (positions, pixels) rather than one property.4. My script that listed each class's rule reported false "NO RULE" lines for names with special characters (
!flex, bg-(--brand)). How to avoid it: check every "missing" with a plain search before believing it.5. I first committed
node_modules into the scratch project's git history. I started again with a .gitignore. How to avoid it: write the ignore file before the first git add.
Hands-On Exercises
Build a small v3 project (JavaScript config, @tailwind directives, @apply, theme(), and classes whose meaning changed). Take a screenshot and 10+ computed styles. Run the upgrade tool, rebuild, and list every difference with its cause. Include the dirty-repo refusal.
Finish the migration by hand: move the config into CSS, fix the markup, and reach zero differing pixels. Then prove whether safelist still works under @config without letting the scanner read the class name.
Build a page of old and new class names with Tailwind 4 and print the rule each one generates. Then test three traps: v3 directives in the v4 CLI, @apply of a removed utility, and the old [--var] syntax. Finally run v3 and v4 side by side with an npm alias.
Chapter 8 Quick Reference
- Decide on browsers first: v4 needs Chrome 111, Safari 16.4, Firefox 128. endoflife.date lists 3.4 as end-of-life 2027-02-28 (third-party; confirm)
npx @tailwindcss/upgrade: Node 20+, refuses a dirty git tree (--forceoverrides), changes templates, CSS, PostCSS config andpackage.json; run it on a branch- In my run it did not convert the JS config (it said so and used
@config); that left a transparent panel, a solid-black "faded" button and a different ring: 4,279 pixels. The manual finish gave 0 - JS config to CSS:
@themevariables,@plugin,@custom-variant dark,@source inline();safelistunder@configis ignored (the scanner can hide this) - Names shifted one step: old
shadow-smisshadow-xs, oldshadowisshadow-sm; same forroundedandblur.ringis 1px currentcolor;ring-3is the old width;outline-nonetooutline-hidden - Removed with no rule and no warning:
bg-opacity-*and its relatives. Usebg-black/50 - Silent breakage in 4.3.3: v3
@tailwinddirectives give a 66-byte stylesheet;bg-[--brand]gives invalid CSS (bg-(--brand)is right);grid-cols-[1fr,2fr]keeps the comma (use underscores) - Side by side:
npm install tailwind3@npm:tailwindcss@3.4.19beside v4 and keep their inputs separate - Verify with a baseline: screenshots plus computed styles before and after
sr-only, contrast and motion preferences, forced colours, logical properties and RTL, and print: styles.
Start with Tailwind CSS Intermediate/Advanced 9.