v3 → v4 Migration
Upgrade a Tailwind v3 project to v4 — the automated codemod, the manual steps, every renamed and removed utility, and the behaviour changes no tool can fix.
Most of a v3 → v4 upgrade is mechanical, and a codemod does it for you. What is left is a short list of behaviour changes — borders, rings, outlines, dividers — that no tool can decide on your behalf. This page covers both halves, in the order you should do them.
The automated upgrade tool#
Run this from the project root:
npx @tailwindcss/upgradeIt upgrades your dependencies, rewrites your CSS entry point, converts tailwind.config.js into a CSS @theme block, and rewrites renamed utilities throughout your template files.
| Requirement | Why |
|---|---|
| Node.js 20 or newer | The tool will not run on Node 18 |
| A clean git working tree | The tool edits files in place across your whole repository |
| A project that currently builds | You want one variable changing at a time |
- Get green on v3 first. Update to the latest v3.4, confirm the build and the test suite pass, and commit.
- Branch.
git switch -c chore/tailwind-v4. - Run the tool.
npx @tailwindcss/upgrade. - Read the diff. Every hunk, not just the CSS. This is the step people skip and then regret.
- Build and look at the app. Compare screens side by side with production.
- Work through the behaviour changes below. These are the ones the tool cannot make for you.
If the tool cannot handle something it says so and leaves the code alone. Treat every skipped file as a manual task, not as a file that needed no work.
Manual migration, step by step#
Do these in order even if you ran the codemod — it is the fastest way to review what it did.
1. Update the packages#
Tailwind v4 splits the build integrations into separate packages. Pick the one that matches your build tool.
npm uninstall tailwindcss postcss-import autoprefixer
npm install tailwindcss @tailwindcss/postcss postcssexport default {
plugins: {
"@tailwindcss/postcss": {},
},
}module.exports = {
plugins: {
'postcss-import': {},
tailwindcss: {},
autoprefixer: {},
},
}npm uninstall tailwindcss postcss-import autoprefixer
npm install tailwindcss @tailwindcss/viteimport { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Then remove Tailwind from postcss.config.js — and delete that file entirely if Tailwind was the only plugin in it.
npm uninstall tailwindcss
npm install tailwindcss @tailwindcss/clinpx @tailwindcss/cli -i ./src/input.css -o ./dist/output.css --watchThe binary moved: npx tailwindcss is the v3 CLI and is no longer the entry point.
2. Replace the @tailwind directives#
@tailwind base;
@tailwind components;
@tailwind utilities;@import "tailwindcss";One import replaces all three directives. The @tailwind directives are gone in v4 and will throw.
3. Move tailwind.config.js into @theme#
Configuration is CSS now. Each config key maps to a theme variable namespace:
tailwind.config.js | @theme namespace |
|---|---|
theme.colors | --color-* |
theme.fontFamily | --font-* |
theme.fontSize | --text-* |
theme.fontWeight | --font-weight-* |
theme.letterSpacing | --tracking-* |
theme.lineHeight | --leading-* |
theme.spacing | --spacing-* |
theme.screens | --breakpoint-* |
theme.borderRadius | --radius-* |
theme.boxShadow | --shadow-* |
theme.dropShadow | --drop-shadow-* |
theme.blur | --blur-* |
theme.aspectRatio | --aspect-* |
theme.transitionTimingFunction | --ease-* |
theme.keyframes + theme.animation | --animate-* plus a plain @keyframes block |
The one rule that catches people: theme.extend adds, a top-level theme key replaces. In CSS, "replace" means resetting the namespace with initial before you declare your own values.
Here is a complete v3 config:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
theme: {
screens: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
extend: {
colors: {
brand: {
50: '#eef2ff',
500: '#4f46e5',
900: '#312e81',
},
},
fontFamily: {
display: ['Satoshi', 'ui-sans-serif', 'sans-serif'],
},
spacing: {
18: '4.5rem',
128: '32rem',
},
borderRadius: {
card: '0.875rem',
},
},
},
plugins: [require('@tailwindcss/typography')],
}And the same thing in v4:
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
/* theme.screens was a top-level key, so it REPLACED the defaults.
Reset the namespace first to reproduce that. */
--breakpoint-*: initial;
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
/* Everything below was under theme.extend, so it just adds. */
--color-brand-50: oklch(0.96 0.02 277);
--color-brand-500: oklch(0.51 0.23 277);
--color-brand-900: oklch(0.36 0.14 279);
--font-display: "Satoshi", ui-sans-serif, sans-serif;
--spacing-18: 4.5rem;
--spacing-128: 32rem;
--radius-card: 0.875rem;
}Three details worth noticing:
contentis gone entirely. See the next step.- Breakpoints are in
rem. v4's defaults arerem-based;640pxand40remare the same at the default root font size, andremkeeps the layout in step when a user changes their browser's text size. - Colours are
oklch(). Hex still works, but the v4 default palette is oklch and mixing the two makes interpolated values (gradients, opacity modifiers) inconsistent.
If a config is too large or too dynamic to convert right now, @config "../tailwind.config.js" keeps loading it — see Legacy Projects for what that supports and what it silently ignores.
darkMode: 'class' has no direct equivalent because there is no config; it becomes a custom variant:
@custom-variant dark (&:where(.dark, .dark *));See Dark Mode for the selector and data-attribute variations.
4. Delete the content array#
v4 finds your templates by itself. It scans the project from the current working directory and skips everything in .gitignore, everything in node_modules, binary files, CSS files and lock files.
You still need @source in four situations:
@import "tailwindcss";
/* 1. A dependency that ships class names — node_modules is ignored by default */
@source "../node_modules/@acmecorp/ui-lib";
/* 2. A directory your .gitignore excludes but that really does contain templates */
@source "../.generated/emails";
/* 3. Excluding something that is scanned but should not be */
@source not "../src/components/legacy";/* 4. Setting the base path explicitly, when the CWD is not the project root */
@import "tailwindcss" source("../src");source(none) disables automatic detection completely, leaving you to list every path with @source — the closest thing to v3's content array, and rarely what you want.
5. Convert plugins to @plugin#
plugins: [
require('@tailwindcss/typography'),
require('@tailwindcss/forms')({ strategy: 'class' }),
]@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms" {
strategy: class;
}@plugin takes a package name or a local path, and options go in a block using CSS syntax rather than a JS object.
Custom plugins you wrote yourself still load through @plugin, but most of them are shorter as @utility or @custom-variant — see Plugins.
Removed utilities#
These have no v4 equivalent under the old name. The codemod rewrites all of them.
| Removed in v4 | Use instead |
|---|---|
bg-opacity-* | bg-black/50 |
text-opacity-* | text-black/50 |
border-opacity-* | border-black/50 |
divide-opacity-* | divide-black/50 |
ring-opacity-* | ring-black/50 |
placeholder-opacity-* | placeholder-black/50 |
flex-shrink-* | shrink-* |
flex-grow-* | grow-* |
overflow-ellipsis | text-ellipsis |
decoration-slice | box-decoration-slice |
decoration-clone | box-decoration-clone |
The opacity utilities were dropped because the / modifier does the same job on any colour utility, composes with variants, and works with arbitrary colours:
<!-- v3 -->
<div class="bg-indigo-500 bg-opacity-50 hover:bg-opacity-75">…</div>
<!-- v4 -->
<div class="bg-indigo-500/50 hover:bg-indigo-500/75">…</div>Renamed utilities#
Same behaviour, different name. The dangerous rows are the ones where a v3 name still exists in v4 with a different value — shadow-sm, blur-sm, rounded-sm. Those do not error; they silently render something subtler than you meant.
| v3 | v4 |
|---|---|
shadow-sm | shadow-xs |
shadow | shadow-sm |
drop-shadow-sm | drop-shadow-xs |
drop-shadow | drop-shadow-sm |
blur-sm | blur-xs |
blur | blur-sm |
backdrop-blur-sm | backdrop-blur-xs |
backdrop-blur | backdrop-blur-sm |
rounded-sm | rounded-xs |
rounded | rounded-sm |
outline-none | outline-hidden |
ring | ring-3 |
bg-gradient-to-* | bg-linear-to-* |
The pattern: every bare utility gained an explicit -sm suffix, and every -sm shifted down to -xs, so the scales now read xs, sm, md, lg, xl with no unnamed member.
bg-gradient-to-* still works as a deprecated alias, so it is the one rename with no visual consequence. Prefer bg-linear-to-* in new code — see Backgrounds & Gradients.
Behaviour changes the tool cannot fix#
Renames are decidable; these are not. Each one needs a human to look at a screen and say whether the new default is what the design wants.
Default border colour is now currentColor#
In v3 a bare border drew a gray-200 line. In v4 it draws one in the element's text colour, so a bordered card with near-black text now has a near-black border.
<!-- v3: implicitly grey -->
<div class="border p-4">…</div>
<!-- v4: state the colour -->
<div class="border border-gray-200 p-4 dark:border-gray-700">…</div><div class="grid gap-4 bg-white p-6 text-sm text-gray-900 sm:grid-cols-2">
<div class="rounded-lg border p-4">border — currentColor in v4</div>
<div class="rounded-lg border border-gray-200 p-4">border border-gray-200</div>
</div>To restore the v3 default across the whole project while you work through it:
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}That is a useful bridge, but being explicit is better — an explicit border colour forces you to think about dark mode. More in Borders.
ring is now 1px currentColor#
v3's bare ring was 3px and blue-500. v4's is 1px and currentColor. Focus rings that used to be obvious blue halos become thin outlines in the text colour.
<!-- v3 look, spelled out -->
<button class="ring-3 ring-blue-500">…</button>
<!-- v4 idiom -->
<button class="focus-visible:ring-2 focus-visible:ring-indigo-500">…</button>Audit every focus:ring in the codebase. This is an accessibility regression if you leave it — a 1px ring in the same colour as the text is easy to miss.
outline-none changed meaning#
This one is a trap because the class still exists and still compiles.
| Class | v3 | v4 |
|---|---|---|
outline-none | outline: 2px solid transparent — invisible, but still present in forced-colours mode | outline-style: none — genuinely removed |
outline-hidden | did not exist | the v3 outline-none behaviour |
If you were using focus:outline-none to hide the browser default before drawing your own ring, you want focus:outline-hidden — otherwise users in Windows High Contrast mode lose the focus indicator entirely. Full detail at outline-none.
space-* and divide-* use a new selector#
Both moved from :not([hidden]) ~ :not([hidden]) to :not(:last-child), which is dramatically faster on large lists.
The consequences:
- Children with their own margins now compose differently, because the spacing lands on a different element.
space-x-*on a row of inline elements can behave unexpectedly; whitespace between inline elements was already part of the layout.- A hidden last child still counts as the last child.
The fix is almost always to stop using space-* where a flex or grid container will do:
<!-- Fragile -->
<div class="space-y-4">…</div>
<!-- Robust -->
<div class="flex flex-col gap-4">…</div>gap is laid out by the container, so hidden children, wrapping and reordering all behave correctly.
Gradient values persist across variants#
In v3, a variant that overrode part of a gradient reset the rest. In v4 the unmentioned stops are preserved, matching how every other utility behaves. That means a three-stop gradient stays three-stop in dark: unless you say otherwise:
<div class="bg-linear-to-r from-red-500 via-orange-400 to-yellow-400
dark:from-blue-500 dark:via-none dark:to-teal-400">…</div>Without dark:via-none, the orange middle stop survives into dark mode and muddies the blue-to-teal ramp.
container lost center and padding#
The container utility no longer has configuration options. In v4 it is an ordinary utility you redefine:
@utility container {
margin-inline: auto;
padding-inline: --spacing(6);
}theme: {
container: { center: true, padding: '2rem' },
}Alternatively, drop container and write mx-auto max-w-7xl px-6 — most modern layouts do. See Container & Columns.
corePlugins, safelist and separator are unsupported#
These three config options have no v4 equivalent, and they are ignored silently even when the file is loaded via @config.
| Option | What to do instead |
|---|---|
corePlugins | Remove the namespace in @theme — --color-*: initial deletes every colour utility. There is no per-plugin off switch. |
safelist | @source inline(…) |
separator | Nothing — : is the separator |
@source inline() is the direct replacement for a safelist, and it brace-expands:
@import "tailwindcss";
/* One class */
@source inline("underline");
/* With variants */
@source inline("{hover:,focus:,}underline");
/* A whole colour range */
@source inline("bg-red-{50,{100..900..100},950}");There is also @source not inline(…) to stop a class being generated even when it is detected in your source.
The theme() function is deprecated#
Theme values are real CSS variables now, so read them directly:
/* Deprecated */
.card { background-color: theme(colors.red.500); }
/* v4 */
.card { background-color: var(--color-red-500); }theme() still resolves, and it is still the answer in media queries, where CSS variables are not allowed. But the argument changed from dot notation to the variable name:
/* v3 */
@media (width >= theme(screens.xl)) { … }
/* v4 */
@media (width >= theme(--breakpoint-xl)) { … }For spacing and alpha there are dedicated functions: --spacing(4) and --alpha(var(--color-lime-300) / 50%). See Functions & Directives.
Colours shifted with oklch#
The default palette was rebuilt in oklch. Two consequences:
- Colours are slightly more vivid on P3 displays. On an sRGB monitor the difference is small; on a modern laptop it is visible.
- Opacity modifiers composite differently.
bg-black/50compiles to acolor-mix()in oklab rather than a flatrgb()with alpha, so overlays and translucent surfaces land on marginally different values.
Neither is a bug, but both will fail a pixel-diff test suite across the board. Re-baseline your visual regression snapshots as one deliberate commit rather than trying to work out which screens "really" changed.
The browser support floor moved#
v4 targets Safari 16.4+, Chrome 111+, and Firefox 128+, because it is built on @property, color-mix() and cascade layers. There is no polyfill and no legacy build.
Other changes worth knowing#
Smaller, but each one has bitten somebody:
| Change | v3 | v4 |
|---|---|---|
| Important modifier | !flex | flex! |
| Prefix | tw-flex (config prefix) | tw:flex (@import "tailwindcss" prefix(tw);) |
| Variant stacking order | right to left — first:*:pt-0 | left to right — *:first:pt-0 |
| CSS variable in an arbitrary value | bg-[--brand] | bg-(--brand) |
| Commas in arbitrary values | grid-cols-[max-content,auto] | grid-cols-[max-content_auto] |
hover on touch devices | always applied | wrapped in @media (hover: hover) |
transition property list | no outline-color | includes outline-color |
| Resetting transforms | transform-none | scale-none, rotate-none, translate-none |
| Placeholder colour | gray-400 | current text colour at 50% opacity |
| Button cursor | pointer | default |
| Theme values in JS | resolveConfig() | getComputedStyle(document.documentElement) |
@apply in a Vue/Svelte <style> block | worked | needs @reference "../../app.css"; first |
The @apply change catches component-heavy Vue and Svelte codebases hard, because scoped <style> blocks are compiled separately and no longer see your theme:
<style>
@reference "../../app.css";
h1 {
@apply text-2xl font-bold;
}
</style>Using the variables directly is faster still — color: var(--color-red-500) needs no Tailwind processing at all.
Migration checklist#
- Confirm the browser floor. Safari 16.4+, Chrome 111+, Firefox 128+ — if that is not acceptable, stop here.
- Get green on v3.4. Latest v3, build passing, tests passing, committed.
- Branch and run the codemod.
npx @tailwindcss/upgradeon Node 20+. - Review the dependency and config diff. Old plugins removed,
postcss-importandautoprefixergone. - Review the CSS entry point. One
@import "tailwindcss", a@themeblock you actually understand. - Delete the redundant plugins. line-clamp, aspect-ratio, container-queries.
- Audit every bare
border. Add an explicit colour or the base-layer bridge. - Audit every
ringandfocus:outline-none. This is the accessibility-critical pass. - Re-check
space-*anddivide-*lists. Convert the fragile ones toflexplusgap. - Search for dynamic class names. Anything that relied on
safelistneeds@source inline(). - Re-baseline visual snapshots. One commit, deliberately, after the oklch shift.
- Walk the app on a real device. Hover on touch, focus rings by keyboard, dark mode.
How long does it take#
The codemod runs in seconds. The review is the work, and it scales with the number of visually distinct screens you have — not with lines of code.
| Project | Realistic estimate |
|---|---|
| A small app, one CSS entry, no custom plugins | The tool plus an afternoon of clicking through screens |
| A product app with a design system and a few hundred components | Two to four days, most of it re-reviewing borders, rings and shadows |
| A monorepo with several apps sharing a UI package | One to two weeks, migrating app by app rather than all at once |
Budget for the audit passes, not for the rename. Teams that hit trouble are almost always the ones that ran the codemod, saw the build succeed, and shipped — the renames are safe, the defaults are what changed. For a monorepo, keep v3 and v4 side by side while you work through it: Legacy Projects covers that setup.
Common mistakes#
- Running the codemod on a dirty working tree. You lose the ability to tell its changes from yours, and there is no way back except reverting everything.
- Assuming a successful build means a successful migration. Almost every v3 class still compiles in v4. The failures are visual, not fatal.
- Leaving
postcss-importandautoprefixerinstalled. v4 does both internally; keeping them can duplicate the whole output. - Skipping the
borderandringaudit. These are the two defaults that changed, and one of them is an accessibility issue. - Swapping
focus:outline-noneforfocus:outline-hiddenblindly. If a component had no visible replacement focus style in v3 either, fix the component instead of preserving the bug. - Expecting
@configto be a full migration. It loads a v3 config, butcorePlugins,safelistandseparatorare ignored without a word. - Converting the whole config in one commit. Nobody can review it and nobody can bisect it.
Related topics#
- Deprecated APIs — the full reference of what was removed, renamed and deprecated
- Legacy Projects — staying on v3.4,
@config, and gradual migration - Theme & Design Tokens — what replaced
tailwind.config.js - Installation — the v4 packages in full
- Troubleshooting — when the build works but the styles do not