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.

Every example here was run, except where marked
Starting point: Tailwind 3.4.19 (the 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.

/** @type {import('tailwindcss').Config} */ module.exports = { darkMode: 'class', content: ['./index.html', './src/**/*.{html,js}'], safelist: ['bg-emerald-500','bg-pink-' + '500'], theme: { extend: { colors: { brand: { 500: '#4f46e5', 600: '#4338ca' }, surface: '#f1f5f9' }, fontFamily: { display: ['Georgia', 'serif'] }, screens: { '3xl': '1920px' }, borderRadius: { card: '0.75rem' }, }, }, plugins: [require('@tailwindcss/typography')], }; /* src/input.css */ @tailwind base; @tailwind components; @tailwind utilities; @layer base { h1 { @apply text-2xl font-bold; } } @layer components { .btn { @apply inline-flex items-center rounded-md bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600 focus:outline-none focus:ring-2; } .panel { background: theme('colors.surface'); border-radius: theme('borderRadius.card'); padding: theme(spacing.4); } }
<!doctype html> <html lang="en"><head><meta charset="utf-8"><link rel="stylesheet" href="dist/out.css"></head> <body class="bg-white p-6 font-sans text-slate-800 dark:bg-slate-900"> <div class="panel shadow max-w-md"> <h1 class="font-display">Migration demo</h1> <p class="mt-2 text-sm text-slate-600">A small page using several things that changed in v4.</p> <div class="mt-4 flex space-x-2"> <button class="btn">Save</button> <button class="btn bg-opacity-50 bg-black" id="b2">Faded</button> <button class="rounded shadow-sm ring border px-3 py-2 outline-none flex-grow" id="b3">Defaults</button> </div> <div class="mt-4 h-8 bg-gradient-to-r from-brand-500 to-emerald-500 blur-sm"></div> <p class="mt-4 overflow-ellipsis !font-bold text-brand-600 3xl:text-xl">Important and ellipsis</p> <div class="mt-4 transform scale-105 rounded-card bg-emerald-500 p-2 text-white">safelisted + transform</div> <article class="prose mt-4"><h2>Prose</h2><p>Typography plugin text.</p></article> </div> </body></html>
baseline: the page built with Tailwind 3.4.19, screenshot v3.png (700 x 700), and a script that prints computed styles for 14 things (border colour, ring, outline, background opacity, panel background, space-x margins, gradient, ...)

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:

npx @tailwindcss/upgrade
│ Git directory is not clean. Please stash or commit your changes before migrating. │ You may use the `--force` flag to silence this warning and perform the migration. (exit code 1, nothing changed)

On the committed project it ran for about 25 seconds and reported:

Upgrading from Tailwind CSS v3.4.19 Linked .\tailwind.config.js to .\src\input.css Migrating JavaScript configuration files… The configuration file at .\tailwind.config.js could not be automatically migrated to the new CSS configuration format, so your CSS has been updated to load your existing configuration file. Migrated stylesheet: .\src\input.css Updated package: tailwindcss Migrated .\index.html Migrated templates for configuration file: .\tailwind.config.js (1 file changed) Installed package: @tailwindcss/postcss Migrated PostCSS configuration: .\postcss.config.js Verify the changes and commit them to your repository.

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:

- @tailwind base; @tailwind components; @tailwind utilities; + @import 'tailwindcss'; + @config '../tailwind.config.js'; + @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { border-color: var(--color-gray-200, currentcolor); } } (a compatibility block, with a comment explaining why) - .btn { @apply ... focus:outline-none focus:ring-2; } (inside @layer components) + @utility btn { @apply ... focus:outline-hidden focus:ring-2; } - .panel { background: theme('colors.surface'); border-radius: theme('borderRadius.card'); padding: theme(spacing.4); } + @utility panel { background: var(--color-surface); border-radius: var(--radius-card); padding: --spacing(4); } index.html: shadow -> shadow-sm | shadow-sm -> shadow-xs | rounded -> rounded-sm | ring -> ring-3 | outline-none -> outline-hidden flex-grow -> grow | bg-gradient-to-r -> bg-linear-to-r | blur-sm -> blur-xs | overflow-ellipsis -> text-ellipsis | !font-bold -> font-bold! postcss.config.js: tailwindcss: {} -> '@tailwindcss/postcss': {} package.json: tailwindcss ^4.3.3, @tailwindcss/postcss ^4.3.3

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):

v3.png vs after-the-tool.png: 4,279 differing pixels of 490,000 computed styles that differ (v3 -> after the tool): #b3 ring: box-shadow ring rgba(59,130,246,0.5) 3px -> 3px ring in the TEXT colour (slate-800) ring now defaults to currentcolor #b2 faded: background rgba(0,0,0,0.5) -> rgb(0,0,0) bg-opacity-50 no longer exists .panel: background rgb(241,245,249), radius 12px -> transparent, radius 0 var(--color-surface) and var(--radius-card) are not defined space-x-2: second button margin-left 8px -> first button margin-right 8px same layout, different selector (expected) border colour, gradient, colours: same colour, now written in oklch/oklab (expected) scale-105 with "transform": transform matrix(1.05,...) -> transform none (the scale property is used) same look

Three of those are real regressions the tool did not (or could not) fix, and none produced a message:

  1. bg-opacity-50 bg-black was left in the markup. In v4 there is no rule for bg-opacity-* (I built it: no output), so the faded button became solid black. The replacement is bg-black/50.
  2. The ring. v3's ring was 3px of 50% blue; ring-3 keeps the width but the default colour is now currentcolor. The guide says to add a colour where you relied on the default: ring-3 ring-blue-500/50.
  3. The panel. The tool rewrote theme('colors.surface') to var(--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 CSSvar(--color-x); spacing: --spacing(4)

I deleted tailwind.config.js, wrote those into input.css, and fixed the three markup problems:

@import 'tailwindcss'; @plugin '@tailwindcss/typography'; @source inline("bg-{emerald,pink}-500"); @custom-variant dark (&:is(.dark *)); @theme { --color-brand-500: #4f46e5; --color-brand-600: #4338ca; --color-surface: #f1f5f9; --font-display: Georgia, serif; --breakpoint-3xl: 1920px; --radius-card: 0.75rem; } @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { border-color: var(--color-gray-200, currentcolor); } h1 { @apply text-2xl font-bold; } } @utility btn { @apply inline-flex items-center rounded-md bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600 focus:outline-hidden focus:ring-2; } @utility panel { background: var(--color-surface); border-radius: var(--radius-card); padding: --spacing(4); } <!-- index.html changes --> <button class="btn bg-black/50" id="b2">Faded</button> <button class="rounded-sm shadow-xs ring-3 ring-blue-500/50 border px-3 py-2 outline-hidden grow" id="b3">Defaults</button> <div class="mt-4 scale-105 rounded-card bg-emerald-500 p-2 text-white">safelisted</div>
v3.png vs after-the-manual-work.png: 0 differing pixels of 490,000 dark mode: <html class="dark"> still turns the body dark (rgb(15,23,42) in v3, the same colour in oklch in v4) the pink safelisted class is generated (1 rule) by @source inline

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:

Classv4.3.3 resultMeaning
bg-opacity-50, text-opacity-50no ruleremoved; use bg-black/50. Silent: the element just loses the effect
shadow / shadow-sm / shadow-xsshadow and shadow-sm are the same rule (1px 3px, the old shadow); shadow-xs is the old shadow-smold shadow-sm silently gets bigger if you do not rename it
rounded / rounded-sm / rounded-xsrounded = 0.25rem; rounded-sm = var(--radius-sm); rounded-xs existsthe same one-step shift as shadows
blur / blur-sm / blur-xsall three existshifted like shadows
ring, ring-3ring is 1px currentcolor; ring-3 is 3px currentcolorwidth 3px to 1px, colour blue-500 to currentcolor
outline-none, outline-hiddenoutline-none: outline-style: none; outline-hidden adds a forced-colors fallbackuse outline-hidden to keep the v3 meaning
flex-grow, flex-shrink-0, overflow-ellipsis, decoration-slicestill produced a rule in 4.3.3the 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-rboth existthe tool renames to bg-linear-to-r
!flex / flex!both produce display: flex !importantnew 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 2frcommas 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

@tailwind base; @tailwind components; @tailwind utilities; /* a v3 stylesheet fed to the v4 CLI, markup: class="p-4 text-red-500" */
exit code 0, "Done in 344ms", output file 66 bytes, 0 rules for text-red-500 @import "tailwindcss"; .x { @apply bg-opacity-50 p-2; } exit code 1 Error: Cannot apply unknown utility class `bg-opacity-50` .y { color: theme(colors.red.500); width: theme(spacing.4); border-color: theme(borderColor.DEFAULT); } -> color: oklch(63.7% 0.237 25.331); width: 1rem; border-color: currentcolor (theme() still resolves old keys)

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:

npm install tailwind3@npm:tailwindcss@3.4.19 tailwindcss@4 @tailwindcss/cli@4 node node_modules/@tailwindcss/cli/dist/index.mjs -i new/in.css -o new/out.css node node_modules/tailwind3/lib/cli.js -c old/tw3.config.js -i old/in.css -o old/out.css
markup: <p class="shadow-sm rounded bg-red-500 p-4"> old/out.css (v3) .shadow-sm { --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); ... } new/out.css (v4) .shadow-sm { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px ...; ... }

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

  1. Decide: browser targets (Chrome 111, Safari 16.4, Firefox 128) and the 3.4 end-of-life date.
  2. Commit everything; make a branch. Take a baseline: screenshots and a few computed styles of pages that matter.
  3. Run npx @tailwindcss/upgrade (Node 20+). Read its output, including what it says it could not do.
  4. Build and check the output has rules for known classes (an empty stylesheet exits 0).
  5. Move the JavaScript config into CSS: @theme, @plugin, @custom-variant, @source inline(). Delete the config and the @config line.
  6. Search for removed and shifted classes: *-opacity-*, ring, outline-none, shadow-*, rounded-*, blur-*, [--var], commas in grid-cols-[...].
  7. Compare screenshots against the baseline; look at every difference.
  8. Remove the compatibility border-colour block only after adding explicit border colours where needed.
Mistakes while preparing this chapter
1. My own v3 baseline was wrong. I wrote 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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 (--force overrides), changes templates, CSS, PostCSS config and package.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: @theme variables, @plugin, @custom-variant dark, @source inline(); safelist under @config is ignored (the scanner can hide this)
  • Names shifted one step: old shadow-sm is shadow-xs, old shadow is shadow-sm; same for rounded and blur. ring is 1px currentcolor; ring-3 is the old width; outline-none to outline-hidden
  • Removed with no rule and no warning: bg-opacity-* and its relatives. Use bg-black/50
  • Silent breakage in 4.3.3: v3 @tailwind directives 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.19 beside v4 and keep their inputs separate
  • Verify with a baseline: screenshots plus computed styles before and after
Coming next
Chapter 9 covers accessibility, internationalisation and print: focus styles, sr-only, contrast and motion preferences, forced colours, logical properties and RTL, and print: styles. Start with Tailwind CSS Intermediate/Advanced 9.