Tailwind CSS Handbook v4.3

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:

BASH
npx @tailwindcss/upgrade

It 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.

RequirementWhy
Node.js 20 or newerThe tool will not run on Node 18
A clean git working treeThe tool edits files in place across your whole repository
A project that currently buildsYou want one variable changing at a time
  1. Get green on v3 first. Update to the latest v3.4, confirm the build and the test suite pass, and commit.
  2. Branch. git switch -c chore/tailwind-v4.
  3. Run the tool. npx @tailwindcss/upgrade.
  4. Read the diff. Every hunk, not just the CSS. This is the step people skip and then regret.
  5. Build and look at the app. Compare screens side by side with production.
  6. 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.

BASH
npm uninstall tailwindcss postcss-import autoprefixer
npm install tailwindcss @tailwindcss/postcss postcss
postcss.config.mjs
export default {
  plugins: {
    "@tailwindcss/postcss": {},
  },
}
Legacy — v3 postcss.config.js
module.exports = {
  plugins: {
    'postcss-import': {},
    tailwindcss: {},
    autoprefixer: {},
  },
}

2. Replace the @tailwind directives#

Legacy — v3 entry point
@tailwind base;
@tailwind components;
@tailwind utilities;
app.css — v4
@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:

Legacy — tailwind.config.js
/** @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:

app.css
@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:

  • content is gone entirely. See the next step.
  • Breakpoints are in rem. v4's defaults are rem-based; 640px and 40rem are the same at the default root font size, and rem keeps 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:

CSS
@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:

CSS
@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";
CSS
/* 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#

Legacy — tailwind.config.js
plugins: [
  require('@tailwindcss/typography'),
  require('@tailwindcss/forms')({ strategy: 'class' }),
]
app.css
@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 v4Use 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-ellipsistext-ellipsis
decoration-slicebox-decoration-slice
decoration-clonebox-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:

HTML
<!-- 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.

v3v4
shadow-smshadow-xs
shadowshadow-sm
drop-shadow-smdrop-shadow-xs
drop-shadowdrop-shadow-sm
blur-smblur-xs
blurblur-sm
backdrop-blur-smbackdrop-blur-xs
backdrop-blurbackdrop-blur-sm
rounded-smrounded-xs
roundedrounded-sm
outline-noneoutline-hidden
ringring-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.

HTML
<!-- 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>
Loading preview…

To restore the v3 default across the whole project while you work through it:

CSS
@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.

HTML
<!-- 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.

Classv3v4
outline-noneoutline: 2px solid transparent — invisible, but still present in forced-colours modeoutline-style: none — genuinely removed
outline-hiddendid not existthe 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:

HTML
<!-- 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:

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

CSS
@utility container {
  margin-inline: auto;
  padding-inline: --spacing(6);
}
Legacy — v3 config that no longer does anything
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.

OptionWhat to do instead
corePluginsRemove the namespace in @theme — --color-*: initial deletes every colour utility. There is no per-plugin off switch.
safelist@source inline(…)
separatorNothing — : is the separator

@source inline() is the direct replacement for a safelist, and it brace-expands:

CSS
@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:

CSS
/* 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:

CSS
/* 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/50 compiles to a color-mix() in oklab rather than a flat rgb() 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:

Changev3v4
Important modifier!flexflex!
Prefixtw-flex (config prefix)tw:flex (@import "tailwindcss" prefix(tw);)
Variant stacking orderright to left — first:*:pt-0left to right — *:first:pt-0
CSS variable in an arbitrary valuebg-[--brand]bg-(--brand)
Commas in arbitrary valuesgrid-cols-[max-content,auto]grid-cols-[max-content_auto]
hover on touch devicesalways appliedwrapped in @media (hover: hover)
transition property listno outline-colorincludes outline-color
Resetting transformstransform-nonescale-none, rotate-none, translate-none
Placeholder colourgray-400current text colour at 50% opacity
Button cursorpointerdefault
Theme values in JSresolveConfig()getComputedStyle(document.documentElement)
@apply in a Vue/Svelte <style> blockworkedneeds @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:

VUE
<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#

  1. Confirm the browser floor. Safari 16.4+, Chrome 111+, Firefox 128+ — if that is not acceptable, stop here.
  2. Get green on v3.4. Latest v3, build passing, tests passing, committed.
  3. Branch and run the codemod. npx @tailwindcss/upgrade on Node 20+.
  4. Review the dependency and config diff. Old plugins removed, postcss-import and autoprefixer gone.
  5. Review the CSS entry point. One @import "tailwindcss", a @theme block you actually understand.
  6. Delete the redundant plugins. line-clamp, aspect-ratio, container-queries.
  7. Audit every bare border. Add an explicit colour or the base-layer bridge.
  8. Audit every ring and focus:outline-none. This is the accessibility-critical pass.
  9. Re-check space-* and divide-* lists. Convert the fragile ones to flex plus gap.
  10. Search for dynamic class names. Anything that relied on safelist needs @source inline().
  11. Re-baseline visual snapshots. One commit, deliberately, after the oklch shift.
  12. 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.

ProjectRealistic estimate
A small app, one CSS entry, no custom pluginsThe tool plus an afternoon of clicking through screens
A product app with a design system and a few hundred componentsTwo to four days, most of it re-reviewing borders, rings and shadows
A monorepo with several apps sharing a UI packageOne 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-import and autoprefixer installed. v4 does both internally; keeping them can duplicate the whole output.
  • Skipping the border and ring audit. These are the two defaults that changed, and one of them is an accessibility issue.
  • Swapping focus:outline-none for focus:outline-hidden blindly. If a component had no visible replacement focus style in v3 either, fix the component instead of preserving the bug.
  • Expecting @config to be a full migration. It loads a v3 config, but corePlugins, safelist and separator are ignored without a word.
  • Converting the whole config in one commit. Nobody can review it and nobody can bisect it.