
Tailwind CSS v3 to v4: The Complete Migration Guide (2026)
Tailwind v4 is a ground-up rewrite. The upgrade tool handles most of it, but the CSS-first config, the renamed utilities, the rebuilt dark mode, and the browser-support floor are where teams get stuck. Exactly what changed and how to move across cleanly.

Mohammed Banani
0
Claps
Tailwind CSS v3 to v4: The Complete Migration Guide (2026)
For most projects, the migration is one command: run npx @tailwindcss/upgrade on a clean branch and let it rewrite your config, your CSS imports, and the renamed utility classes. It handles the bulk of the work correctly. The reason this guide exists is the part the tool cannot fully automate: the new CSS-first config model, a handful of breaking utility changes that need a human eye, the rewritten dark mode setup, and one browser-support cliff that catches teams by surprise.
Read the breaking-change sections before you assume the codemod caught everything. It usually does. The exceptions are expensive to find in production.
What changed, and why you should care
Tailwind v4 is a ground-up rewrite, not a version bump. The team replaced the old PostCSS-based engine with a new one called Oxide, written partly in Rust, with Lightning CSS built in for parsing, autoprefixing, and minification. By Tailwind's own benchmarks, full builds run up to about 5x faster and incremental rebuilds over 100x faster, fast enough that the rebuild stops being something you notice.
The visible change is philosophical. In v3, Tailwind was configured in JavaScript through tailwind.config.js. In v4, the default is to configure it in CSS. Your design tokens become CSS custom properties, your theme lives in an @theme block, and the framework leans on native CSS features like cascade layers, @property, and color-mix(). That last point is also the catch, and we will get to it.
Here is the shape of the change at a glance.
| Area | v3 | v4 |
|---|---|---|
| Engine | PostCSS plugin | Oxide, Lightning CSS built in |
| Config | tailwind.config.js |
@theme in CSS (JS config still loadable) |
| Import | @tailwind base; etc. |
@import "tailwindcss"; |
| PostCSS plugin | tailwindcss |
@tailwindcss/postcss |
| Vite | PostCSS | @tailwindcss/vite plugin |
| Browser floor | older browsers | Safari 16.4+, Chrome 111+, Firefox 128+ |
The fast path: the upgrade tool
Do not migrate by hand. The official codemod does in seconds what would take you an afternoon of find-and-replace, and it makes fewer mistakes.
# Start on a clean branch so you can read the diff
git checkout -b tailwind-v4
# Node 20 or newer is required
npx @tailwindcss/upgradeThe tool migrates your dependencies, converts tailwind.config.js into the new CSS @theme format where it can, rewrites your @tailwind directives to the new @import, and renames the utility classes that changed. When it finishes, your job is to read the diff carefully and run the app.
Two ground rules. Run it on a project already on the latest v3.4, not an ancient version, so the codemod has less to reconcile. And review the generated CSS, because anything custom in your old JS config that does not map cleanly will be left for you to port by hand.
Install and import changes
If you ever set up Tailwind manually, or your codemod run needs a nudge, these are the moving parts.
The import directives collapse into one line:
/* v3 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* v4 */
@import "tailwindcss";The PostCSS plugin moved to its own package, which trips up custom build setups:
// postcss.config.mjs
// v3
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
// v4: autoprefixer is no longer needed, Lightning CSS handles it
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};On Vite, skip PostCSS entirely and use the dedicated plugin, which is faster:
// vite.config.ts
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()],
});Config moves into CSS
This is the change with the steepest learning curve, so it is worth slowing down. In v4, your theme is defined in CSS using @theme, and every token becomes a real CSS custom property.
/* v3: tailwind.config.js */
module.exports = {
theme: {
extend: {
colors: {
brand: "#0ea5e9",
},
fontFamily: {
display: ["Space Grotesk", "sans-serif"],
},
},
},
};/* v4: in your CSS, after the import */
@import "tailwindcss";
@theme {
--color-brand: #0ea5e9;
--font-display: "Space Grotesk", sans-serif;
}The naming is structured, not arbitrary. A custom property named --color-brand generates bg-brand, text-brand, border-brand, and the rest, because the --color-* namespace maps to color utilities. The same pattern applies to --font-*, --spacing-*, --breakpoint-*, --radius-*, and so on. Learn the namespaces once and the system is predictable.
The payoff is that every token is now a live CSS variable at runtime. You can read var(--color-brand) in hand-written CSS, override it inside a media query, or change it with JavaScript for theming, none of which was possible when the values lived in a JS object. The CSS Variables Generator is handy for sketching a token set before you paste it into @theme, and the Tailwind Config Generator helps if you prefer to start from a familiar config shape and convert.
If you have a large, complex JS config you are not ready to rewrite, you do not have to. You can keep it and load it explicitly:
@import "tailwindcss";
@config "../tailwind.config.js";That is a legitimate stepping stone. Ship the migration with the old config attached, then port it into @theme later when you have time.
Breaking changes that actually bite
The upgrade tool catches most renamed utilities. These are the ones worth understanding, because a silent visual shift is harder to debug than a build error.
The default border color changed. In v3, border with no color gave you gray-200. In v4, it defaults to currentColor. Any element with a bare border class will suddenly draw in the current text color. The fix is to be explicit:
<!-- v3 relied on the gray-200 default -->
<div class="border">...</div>
<!-- v4: state the color you meant -->
<div class="border border-gray-200">...</div>Shadow, radius, and blur scales shifted down a step. The old default names were rescaled so there is room for smaller values. If something looks heavier or rounder than before, this is usually why.
| v3 | v4 |
|---|---|
shadow-sm |
shadow-xs |
shadow |
shadow-sm |
rounded-sm |
rounded-xs |
rounded |
rounded-sm |
blur-sm |
blur-xs |
blur |
blur-sm |
outline-none was renamed. What you wrote as outline-none in v3, meaning a transparent 2px outline kept for accessibility, is now outline-hidden. The new outline-none actually sets outline-style: none. This is a small change that caused us a real bug on consolelog.tools: a search input inside a popover kept showing the global focus ring because the old class name no longer did what we assumed. Read these two as different utilities now.
The default ring width dropped from 3px to 1px. A bare ring is now 1px. If your focus states got thinner after upgrading, add ring-3 to restore the old look, or set your preferred default in @theme.
Opacity utilities are gone. bg-opacity-50, text-opacity-*, and the rest were removed in favor of the slash syntax, which had been the recommended approach for a while anyway:
<!-- v3 -->
<div class="bg-black bg-opacity-50">...</div>
<!-- v4 -->
<div class="bg-black/50">...</div>Hover only applies where hover exists. v4 wraps hover: styles in @media (hover: hover), so they no longer fire on touch devices that emulate hover on tap. This is usually what you want, but if you depended on tap-triggered hover styling, you will notice it stopped.
If you want to inspect what a class compiles to while you reconcile these, the Tailwind to CSS Converter shows the generated declarations for any class string.
Dark mode, rebuilt
Dark mode is where the most migration questions come from, because the configuration moved.
In v3, you flipped a config flag:
// tailwind.config.js
module.exports = {
darkMode: "class",
};In v4, the media strategy (follow the OS preference) is the default and needs no setup. If you want the class-based strategy, where a .dark class on a parent toggles the theme, you declare it as a custom variant in CSS:
@import "tailwindcss";
/* Toggle dark mode with a `dark` class instead of the OS preference */
@custom-variant dark (&:where(.dark, .dark *));After that, dark:bg-gray-900 behaves exactly as it did in v3 when the .dark class is present. The difference is purely where you configure it. If you use a data attribute instead of a class, point the variant at the attribute:
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));The browser-support cliff
This is the change most likely to surprise you after a smooth upgrade, so check it before you commit.
Tailwind v4 targets modern browsers and uses native features that older ones do not support: cascade layers, the @property rule, and color-mix(). The practical floor is Safari 16.4, Chrome 111, and Firefox 128, all from early 2023 onward. On browsers older than that, the framework does not gracefully degrade. Things like the opacity modifiers and custom-property theming simply break.
For the large majority of products this is a non-issue, because those browser versions are years old. But if your analytics show meaningful traffic from older Safari on long-lived iOS devices, or you have an enterprise audience pinned to old browsers, confirm your real support targets before migrating. The Browserslist Generator helps you turn a support policy into a concrete browser list you can check your traffic against. If a chunk of your users sit below the v4 floor, staying on v3.4 for now is a defensible call, not a failure.
After the migration: cleanup
Once the app builds and looks right, a short cleanup pass pays off.
Sort your classes. v4 ships an updated Prettier plugin, prettier-plugin-tailwindcss, that orders utility classes consistently. Consistent ordering makes diffs smaller and merge conflicts rarer. The Tailwind Class Sorter does the same job for one-off pasted snippets when you are not running Prettier.
Drop dead CSS. A migration is a good moment to find selectors nothing references anymore, especially if you carried hand-written CSS alongside Tailwind. The Unused CSS Detector flags rules with no matching markup.
Revisit responsive and container work. v4 has first-class container queries with @container and the @min-* and @max-* variants, so layout that you previously forced with viewport breakpoints might be cleaner as a container query. The Container Queries Generator and the Media Query Generator are useful while you decide which fits.
Should you migrate now?
For a new project, start on v4. There is no reason to begin on the previous engine.
For an existing project, the answer depends on two things: your browser-support floor and your appetite for a focused afternoon of work. If your users are on current browsers and you can dedicate the time to run the codemod and walk the breaking-change list, the build-speed gain and the cleaner config model are worth it. If you have a hard requirement to support browsers from before 2023, or you are mid-crunch on something that matters more, v3.4 is stable and supported, and waiting a quarter costs you nothing.
When you do migrate, the order that works is: branch, run npx @tailwindcss/upgrade, read the diff, fix the border-color and outline changes by hand, set up dark mode if you use the class strategy, confirm your browser targets, then run the cleanup pass.
Build and check your Tailwind output as you go, all in the browser and free: Tailwind to CSS Converter, Tailwind Config Generator, CSS Variables Generator, Tailwind Class Sorter, and the Browserslist Generator to pin down who you actually need to support.

