Tailwind CSS v4: migrating from v3
A complete v3 to v4 migration: the CSS-first @theme config, the automated upgrade tool, and every silent default change that ships a visual regression without erroring.
- Step 1
Check your browser and Node targets first
Tailwind v4 is built on modern CSS —
@property,color-mix(), cascade layers — and it does not polyfill them. The supported floor is Safari 16.4+, Chrome 111+, and Firefox 128+. If your analytics still show meaningful traffic below those versions, stay on v3.4; there is no compatibility mode. The automated upgrade tool also requires Node.js 20 or newer.# Confirm Node 20+ before doing anything else node --version # Check what you're currently on npm ls tailwindcss⚠ Heads up: There is no v4 build target for older browsers. Verify your traffic before migrating — this is a one-way door for IE-era or older Safari users. - Step 2
Run the migration on a clean branch
The upgrade tool rewrites CSS, config, and template files in place. Commit or stash everything first so
git diffshows you exactly what it changed — reviewing that diff is the single most valuable step in the whole migration.git checkout -b tailwind-v4 git status # must be clean npx @tailwindcss/upgrade - Step 3
What the upgrade tool does and does not do
It handles the mechanical work: swapping dependencies, converting
tailwind.config.jsinto CSS@themevariables, and renaming utility classes across your templates. What it cannot do is find class names your code builds at runtime — string concatenation, template literals, or class maps in a CMS. Those you must grep for by hand.# Classes assembled at runtime are invisible to the codemod. # This will NOT be migrated for you: const cls = `shadow-${size}` // shadow-sm silently changed meaning const variants = { small: 'rounded' } // 'rounded' silently changed meaning # Grep for the risky renames across the whole repo: grep -rnE "(^|[\"' ])(shadow|rounded|blur|drop-shadow|backdrop-blur)([\"' ]|$)" src/⚠ Heads up: Dynamically constructed class names are the most common source of post-migration visual bugs. The build succeeds and the styles are simply wrong. - Step 4
Swap the build plugin
Tailwind no longer ships as a PostCSS plugin under its own name. Which package you need depends on your bundler. If you are on Vite, the dedicated plugin is meaningfully faster than going through PostCSS and is the recommended path. Note that
postcss-importandautoprefixerare now handled internally — remove them.// PostCSS — postcss.config.mjs export default { plugins: { "@tailwindcss/postcss": {}, }, }; // Vite — vite.config.ts (preferred) import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [tailwindcss()], }); // CLI — the binary moved to its own package // v3: npx tailwindcss -i input.css -o output.css // v4: npx @tailwindcss/cli -i input.css -o output.css - Step 5
Replace the @tailwind directives with a single import
The three
@tailwinddirectives collapse into one standard CSS import. This is a real@import, so it must come before other rules in the file./* v3 */ @tailwind base; @tailwind components; @tailwind utilities; /* v4 */ @import "tailwindcss"; - Step 6
Move your theme into CSS with @theme
Design tokens now live in CSS as custom properties inside
@theme, and each one generates the matching utilities. The variable namespace determines what gets generated:--color-*createsbg-*/text-*/border-*,--breakpoint-*creates responsive variants,--font-*createsfont-*. Because they are real CSS variables, they are also readable at runtime — which replaces v3'sresolveConfig().@import "tailwindcss"; @theme { --color-brand-100: oklch(0.97 0.02 265); --color-brand-500: oklch(0.62 0.19 265); --font-display: "Inter", sans-serif; --breakpoint-3xl: 120rem; --spacing: 0.25rem; } /* Generates: bg-brand-500, text-brand-100, font-display, 3xl:flex, ... */ /* Reading theme values from JS (resolveConfig() is gone in v4): */ /* const styles = getComputedStyle(document.documentElement); const brand = styles.getPropertyValue("--color-brand-500"); */ - Step 7
Keep a JavaScript config only if you must
If your config has logic the CSS syntax cannot express,
@configwill load the old file — but treat it as a transitional crutch, not a destination. Three options are simply gone:corePlugins,safelist, andseparator. Safelisting has a direct replacement in@source inline()./* Load a legacy JS config */ @import "tailwindcss"; @config "../../tailwind.config.js"; /* Safelist replacement — corePlugins/safelist/separator are unsupported */ @source inline("bg-red-500 bg-green-500 bg-blue-500");⚠ Heads up: `corePlugins`, `safelist`, and `separator` are silently ignored under `@config`. If you relied on `safelist` for CMS-driven classes, migrate to `@source inline()` or those styles will vanish from your build. - Step 8
The renamed scales that silently change your design
This is the change most likely to ship a subtle regression. Every default-sized shadow, radius, and blur shifted down one step to make room for a new extra-small size. The old class names still exist and still compile — they just render smaller than before. Nothing errors.
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⚠ Heads up: A bare `shadow` or `rounded` left in your markup now renders one size smaller than it did in v3. No warning, no error — just a slightly different design. - Step 9
Opacity utilities are gone — use the slash syntax
The paired
*-opacity-*utilities were removed in favour of the modifier syntax that v3 already supported. Anywhere you set a colour and its opacity as two classes, collapse them into one.<!-- v3 --> <div class="bg-black bg-opacity-50 text-white text-opacity-75"> <!-- v4 --> <div class="bg-black/50 text-white/75"> <!-- Also removed: --> <!-- border-opacity-* divide-opacity-* ring-opacity-* placeholder-opacity-* --> <!-- flex-shrink-* → shrink-* flex-grow-* → grow-* --> <!-- overflow-ellipsis → text-ellipsis --> - Step 10
Default values that changed underneath you
Several defaults were changed to be less opinionated. Each one is a visual change that the compiler cannot warn you about. If you would rather not audit every usage, the compatibility CSS below restores the v3 behaviour wholesale.
@layer base { /* Borders defaulted to gray-200; they now default to currentColor */ *, ::after, ::before, ::backdrop, ::file-selector-button { border-color: var(--color-gray-200, currentColor); } /* Placeholders were gray-400; now current color at 50% opacity */ input::placeholder, textarea::placeholder { color: var(--color-gray-400); } /* Buttons were cursor-pointer; now cursor-default */ button:not(:disabled), [role="button"]:not(:disabled) { cursor: pointer; } } /* Rings went from 3px/blue-500 to 1px/currentColor. Either write ring-3 ring-blue-500 explicitly, or: */ @theme { --default-ring-width: 3px; --default-ring-color: var(--color-blue-500); }⚠ Heads up: `--default-ring-width` and `--default-ring-color` are deprecated escape hatches. Prefer fixing the call sites so you are not carrying compatibility shims forward. - Step 11
outline-none now means something different
This one is an accessibility trap. In v3,
outline-noneremoved the visible outline while leaving a transparent one for forced-colors mode. In v4 that behaviour is calledoutline-hidden, andoutline-nonegenuinely setsoutline-style: none.<!-- v3 behaviour is now called outline-hidden --> <input class="focus:outline-none" /> <!-- v3 --> <input class="focus:outline-hidden" /> <!-- v4 equivalent --> <!-- `outline` also now sets 1px by default, so the width class is enough --> <input class="outline outline-2" /> <!-- v3 --> <input class="outline-2" /> <!-- v4 -->⚠ Heads up: Leaving `focus:outline-none` in place removes the focus indicator for Windows High Contrast users. Change it to `outline-hidden` unless you truly want no outline at all. - Step 12
space-y and divide use a different selector
Both utilities switched from
:not([hidden]) ~ :not([hidden])to:not(:last-child), which is dramatically faster on large lists but behaves differently when children are conditionally hidden. For new markup, flex or grid withgapis the better construct./* v3 */ .space-y-4 > :not([hidden]) ~ :not([hidden]) { margin-top: 1rem; } /* v4 — note it now sets margin-bottom on all but the last child */ .space-y-4 > :not(:last-child) { margin-bottom: 1rem; }⚠ Heads up: If you toggle child visibility with the `hidden` attribute, spacing will now render around the hidden element. Switch those containers to `flex flex-col gap-4`. - Step 13
Arbitrary value and modifier syntax
Three syntax changes that produce build-time errors rather than silent breakage — easier to catch, but you need to know the new forms. CSS variables in arbitrary values now use parentheses, commas become underscores, and
!importantmoved to the end of the class.<!-- CSS variables: square brackets → parentheses --> <div class="bg-[--brand-color]"></div> <!-- v3 --> <div class="bg-(--brand-color)"></div> <!-- v4 --> <!-- Commas in arbitrary values become underscores --> <div class="grid-cols-[max-content,auto]"></div> <!-- v3 --> <div class="grid-cols-[max-content_auto]"></div> <!-- v4 --> <!-- Important modifier moved to the end --> <div class="!flex hover:!bg-red-600"></div> <!-- v3 --> <div class="flex! hover:bg-red-600!"></div> <!-- v4 --> <!-- Stacked variants now read left to right --> <ul class="py-4 first:*:pt-0"></ul> <!-- v3 --> <ul class="py-4 *:first:pt-0"></ul> <!-- v4 --> - Step 14
Custom utilities move from @layer to @utility
Registering your own utilities through
@layer utilitiesno longer makes them variant-aware. The@utilitydirective replaces it and integrates properly withhover:,md:, and the rest. Thecontainerutility also lost itscenterandpaddingconfig options and is now customised the same way./* v3 */ @layer utilities { .tab-4 { tab-size: 4; } } /* v4 */ @utility tab-4 { tab-size: 4; } /* container no longer takes center/padding options — override it directly */ @utility container { margin-inline: auto; padding-inline: 2rem; } /* Multi-property component classes still belong in @layer components */ @layer components { .btn { border-radius: 0.5rem; padding: 0.5rem 1rem; } } - Step 15
Using @apply inside component frameworks
Vue SFCs, Svelte components, and CSS Modules each compile their
<style>blocks in isolation, so they have no idea what your theme is.@applythere needs an explicit@referenceto your main stylesheet — or, better, just use the CSS variables directly, which skips the compilation step entirely.<style> /* Tell the isolated style block where the theme lives */ @reference "../../app.css"; h1 { @apply text-2xl font-bold text-red-500; } </style> <style> /* Simpler and faster — no @reference needed */ h1 { color: var(--color-red-500); font-size: var(--text-2xl); } </style>⚠ Heads up: Tailwind v4 is not compatible with Sass, Less, or Stylus. If your project preprocesses CSS, that layer has to come out before you migrate. - Step 16
Verify the migration
Work through this list before merging. The build passing proves very little here — most v4 regressions are visual, and every one of them compiles cleanly.
# 1. Build succeeds and the CSS bundle is a sane size npm run build # 2. No stale v3 packages left behind npm ls tailwindcss postcss-import autoprefixer # 3. No leftover directives or removed utilities grep -rn "@tailwind " src/ grep -rnE "(bg|text|border|ring|divide|placeholder)-opacity-" src/ grep -rnE "flex-(shrink|grow)-" src/ grep -rn "outline-none" src/ # 4. Eyeball the things that changed silently: # - shadows and border radii (one size smaller) # - border colors (currentColor, not gray-200) # - focus rings (1px currentColor, not 3px blue) # - focus visibility in forced-colors mode # - any list using space-y with conditionally hidden children
Feature requests
Sign in to suggest features or vote on existing ones.
No feature requests yet.
Discussion
Sign in to join the discussion.
No comments yet.