Tailwind CSS v3 to v4 Migration
The path. Audit first, automate second, verify third. Skipping the audit produces a build that compiles and renders wrong.
Companion skills :
tailwind-core-v3-vs-v4: decision tree for staying vs upgradingtailwind-errors-v4-migration: trap catalogue after upgradetailwind-impl-config-v4: v4 CSS-first config surfacetailwind-impl-config-v3: v3 JS config surface (source state)
Decision : Do You Even Migrate
| Constraint | Stay on v3.4 | Migrate to v4 |
|---|---|---|
| Must support Safari < 16.4, Chrome < 111, Firefox < 128 | yes | no |
| Uses Sass / Less / Stylus around Tailwind | yes | no |
Heavy reliance on corePlugins: { x: false } to disable utilities |
stay or accept utility cannot be disabled in v4 | no |
Uses resolveConfig in JS at runtime |
yes | only if you rewrite to getComputedStyle |
| Greenfield project, modern browsers | no | yes |
| Want CSS-first config and faster builds | no | yes |
v3.4 still receives security patches. There is NO urgency to migrate purely for the sake of being current.
Quick Path : Automated Upgrade
git checkout -b chore/tailwind-v4
npx @tailwindcss/upgrade
Requires Node.js 20 or higher. The tool runs in three phases :
- Dependency updates : removes
tailwindcss@^3,autoprefixer,postcss-import; installstailwindcss@^4,@tailwindcss/postcssor@tailwindcss/vitebased on detected build setup. - Config conversion : reads
tailwind.config.js, convertstheme.extend.colors,fontFamily,spacing, etc. into a CSS@theme { ... }block in the entry stylesheet. Leaves the JS file in place with a comment, or removes it (review diff). - Template rewrites : runs codemods across
.html,.tsx,.vue,.sveltefor the deterministic renames (shadow→shadow-sm,flex-shrink-0→shrink-0,bg-opacity-50→ adjusted variants, etc.) and the variant-stacking-order flip.
ALWAYS review the diff. The tool does NOT cover :
- Dynamic class strings :
`bg-opacity-${n}`is invisible. - Scoped
@applyblocks in.vue/.svelte/ CSS-modules (requires manual@referenceper file). - Custom plugins that read
theme()in patterns the codemod misses. - Visual regressions from default
border-color,ring-color,ring-width,placeholder-colorchanges.
Pre-Migration Audit Checklist
ALWAYS complete BEFORE running the upgrade tool :
- corePlugins : grep
tailwind.config.jsforcorePlugins. Every disabled utility now exists in v4 with no way to disable. Plan to add a lint rule or scope-limit content scanning. - safelist : grep
tailwind.config.jsforsafelist. Migrate to@source inline("class-list")in CSS post-upgrade. - separator : grep for
separator:in config. v4 fixes the separator to:. Custom separators must be removed. - prefix : if you use
prefix: 'tw-'in v3, plan to convert allclass="tw-flex"toclass="tw:flex". The tool handles common cases ; verify dynamic class strings. - resolveConfig : grep
node_modules(your own packages) fortailwindcss/resolveConfig. Every call site needs rewriting togetComputedStyle(document.documentElement).getPropertyValue(...). - Default border colour : grep for
<X class="border"(no colour). Each becomescurrentColorin v4 ; pages may render borders in unexpected colours. Either add explicitborder-zinc-200everywhere, or restore the v3 default via@layer base. - Default ring : grep for
class="ring". The new default is 1pxcurrentColor. Decide between adding explicitring-3 ring-blue-500or preserving v3 globally via@theme { --default-ring-width: 3px; --default-ring-color: var(--color-blue-500); }. - Sass / Less / Stylus : v4 does NOT work alongside CSS
preprocessors. If your build runs
.scssthrough Sass before PostCSS, plan to flatten to plain CSS first. - Scoped
@apply: grep.vue,.svelte, CSS-module files for@apply. Each scoped stylesheet needs@reference "../app.css";prepended in v4. - Dynamic opacity : grep for
bg-opacity-,text-opacity-, etc. in dynamic templates. The codemod converts literal class strings but cannot rewrite string concatenation.
Manual Breaking Changes (After Tool Runs)
Renamed utility scale shift
Every size scale shifted by one. v3 shadow (the default with no
suffix) is v4 shadow-sm. v3 shadow-sm (the small one) is v4
shadow-xs. Same shift for blur, drop-shadow, backdrop-blur,
rounded.
| v3 | v4 |
|---|---|
shadow-sm |
shadow-xs |
shadow |
shadow-sm |
blur-sm |
blur-xs |
blur |
blur-sm |
rounded-sm |
rounded-xs |
rounded |
rounded-sm |
drop-shadow-sm |
drop-shadow-xs |
drop-shadow |
drop-shadow-sm |
backdrop-blur-sm |
backdrop-blur-xs |
backdrop-blur |
backdrop-blur-sm |
Renamed outline and ring
outline-nonebecomesoutline-hidden(the v4outline-noneremoves outline entirely, a different intent).ring(v3 default 3px) becomesring-3to preserve 3px.
Removed deprecated utilities
| v3 | v4 |
|---|---|
bg-opacity-50 |
bg-black/50 modifier syntax |
text-opacity-* |
text-{color}/{n} |
border-opacity-* |
border-{color}/{n} |
divide-opacity-* |
divide-{color}/{n} |
ring-opacity-* |
ring-{color}/{n} |
placeholder-opacity-* |
placeholder-{color}/{n} |
flex-shrink-* |
shrink-* |
flex-grow-* |
grow-* |
overflow-ellipsis |
text-ellipsis |
decoration-slice |
box-decoration-slice |
decoration-clone |
box-decoration-clone |
bg-gradient-to-r |
bg-linear-to-r (also bg-radial, bg-conic) |
Variant-stacking order flip
v3 evaluated right-to-left. v4 evaluates left-to-right :
<!-- v3 -->
<ul class="first:*:pt-0 last:*:pb-0">
<!-- v4 -->
<ul class="*:first:pt-0 *:last:pb-0">
The upgrade tool catches common cases. Stacked custom variants need manual inspection.
Important modifier position
v3 placed ! at the front (!flex). v4 places it at the end (flex!).
Variables in arbitrary values
v3 used brackets. v4 uses parentheses :
<!-- v3 -->
<div class="bg-[--brand]"></div>
<!-- v4 -->
<div class="bg-(--brand)"></div>
Arbitrary commas become underscores
v3 grid template lists used commas. v4 uses underscores for spaces :
<!-- v3 -->
<div class="grid-cols-[max-content,auto]"></div>
<!-- v4 -->
<div class="grid-cols-[max-content_auto]"></div>
Prefix syntax flips
v3 prefixed utility names (tw-flex). v4 treats prefix as a variant
(tw:flex). Configure in CSS :
@import "tailwindcss" prefix(tw);
Default colour changes
| What | v3 default | v4 default |
|---|---|---|
border colour |
gray-200 |
currentColor |
ring colour |
blue-500 |
currentColor |
ring width |
3px |
1px |
placeholder colour |
gray-400 |
currentColor at 50% opacity |
Restore v3 behaviour globally :
@theme {
--default-ring-width: 3px;
--default-ring-color: var(--color-blue-500);
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
input::placeholder,
textarea::placeholder {
color: var(--color-gray-400);
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
dialog {
margin: auto;
}
}
Hover gating
v4 wraps hover: in @media (hover: hover). Mobile devices that
emulate hover via touch will NOT fire it. Restore v3 behaviour :
@custom-variant hover (&:hover);
theme() function deprecation
v3 used theme(colors.red.500). v4 prefers CSS variables :
/* v3 */
.x { background: theme(colors.red.500); }
/* v4 */
.x { background: var(--color-red-500); }
The legacy theme(...) still works in v4 with the new dash-prefixed
syntax theme(--color-red-500), but plain var(...) is preferred.
corePlugins, safelist, separator REMOVED
No JS-config replacement. Migrate :
corePlugins: accept utilities cannot be disabled ; restrict via@source not "path"or a lint rule.safelist:@source inline("class-list")in CSS.separator: v4 fixes the separator to:.
resolveConfig REMOVED
No JS API to read the resolved config. Use the DOM :
const styles = getComputedStyle(document.documentElement);
const red = styles.getPropertyValue('--color-red-500');
Dual-Version Strategy (Monorepo)
When a shared component library is consumed by multiple apps and you cannot upgrade them all simultaneously :
- Library exposes Tailwind classes as plain strings (no
@applyin library source ;@applysemantics differ between v3 and v4). - Each app installs its own Tailwind version. v3 apps keep
tailwindcss@^3+postcss-import+autoprefixer. v4 apps installtailwindcss@^4+@tailwindcss/postcss. - Library publishes a
tailwind-tokenspackage : in v3 a JS config fragment fortheme.extend, in v4 a CSS file with@theme { ... }. - Each app imports the appropriate variant.
- Migrate one app at a time. ALWAYS keep the library at the lowest
common denominator (utilities only ; no
@apply, no custom directives).
Post-Migration Verification
- Build succeeds :
npm run buildexits 0. CSS bundle generated. - Bundle inspection : grep the output for selectors known to have
changed (
shadow-xs,outline-hidden, etc.). - Visual regression : screenshot the top 20 pages before the merge, again after, diff with a tool (Percy, Playwright, Chromatic). Default-colour shifts (border, ring, placeholder) WILL produce diffs ; review each.
- Scoped @apply scan : grep
find . -name '*.vue' -o -name '*.svelte' -o -name '*.module.css' | xargs grep -l '@apply'. Every match needs@reference "../app.css";at the top of the<style>block. - Dynamic class smoke test : navigate to pages that build class
names from props (
bg-opacity-${n}, etc.). These cases the codemod could not rewrite. - Tailwind plugin compatibility : check every entry in
tailwind.config.js'spluginsarray. Most v3 plugins still work in v4 (loaded via@plugin "..."in CSS), but anything that touchescorePluginsorsafelistconfig will silently no-op.
References
references/methods.md: per-area migration procedures with exact commands and shell snippetsreferences/examples.md: before/after code for every breaking change in this cataloguereferences/anti-patterns.md: ten migration traps with symptom, cause, fix, verification