v3 → v4: config → CSS-first
v4 moves the source of truth out of tailwind.config.js and into
your CSS. Theme tokens become CSS variables under @theme, the
three @tailwind directives collapse to one
@import, several utilities were renamed for consistency, the
default border color is now currentColor, and dark mode is a
one-line @custom-variant. The automated codemod
(npx @tailwindcss/upgrade) rewrites ~90% of this — but it
can't catch semantic mistakes (a bare border that now inherits
text color), so you still need the table below.
| area | v3 (JS config / old syntax) | v4 (CSS-first) |
|---|---|---|
| config file | tailwind.config.js (required) |
optional — @config bridges a legacy file; most tokens move into @theme |
| directives | @tailwind base; @tailwind components; @tailwind utilities; |
@import "tailwindcss"; |
| theme tokens | theme.extend.colors.brand |
@theme { --color-brand: #06b6d4; } |
| content / source | content: [...] array |
auto-detect + @source directive (see source detection) |
| dark mode | darkMode: 'class' |
@custom-variant dark (&:where(.dark, .dark *)); |
| default border color | gray-200 |
currentColor |
| opacity shorthand | text-black opacity-50 |
text-black/50 (slash works for any color util) |
| variant stacking | applied right-to-left (innermost-first) | applied left-to-right (outermost-first) — the literal order you read |
0 · live proof — the renamed v4 utilities compile
This page loads the real Tailwind v4 Play CDN. The two cells below are
styled only by v4-renamed utilities. The gold-check at the top of
the page runs getComputedStyle() on them to prove the
renames map correctly: bg-linear-to-r must produce a real
linear-gradient(...), and a bare border must
resolve its color to currentColor (the text color), not
gray-200.
class: bg-linear-to-r from-cyan-400 to-blue-500 (was bg-gradient-to-r)
class: border + text-cyan-400 (border default = currentColor now)
grad.background-image: — · border.borderColor: —
1 · interactive migration checker — pick a category
Click a category button. The left column shows v3 code (what the codemod
rewrites from); the right shows the v4 equivalent (what it rewrites
to). Every entry is something npx @tailwindcss/upgrade
handles automatically — except where flagged in the gotchas panel.
v3 — before
v4 — after
2 · renamed utilities — the full table
These are mechanical renames the codemod does for you. The why
column is the consistency rule behind each rename. (Any v3 name still in
your code after the codemod is a bug — verify with node --check
on your build or a visual diff.)
| v3 | v4 | why |
|---|---|---|
bg-gradient-to-r | bg-linear-to-r | new bg-linear-* + bg-radial-* + bg-conic-* family — names match the CSS function |
bg-gradient-to-br etc. | bg-linear-to-br etc. | same family rename — all bg-gradient-* → bg-linear-* |
flex-grow | grow | shorter; mirrors the CSS property flex-grow → utility root grow |
flex-shrink | shrink | shorter; same rule as grow |
overflow-ellipsis | text-ellipsis | it's a text-* property (text-overflow), not an overflow one |
decoration-clone | text-decoration-clone | matches the CSS property text-decoration-color family |
decoration-slice | text-decoration-slice | same family |
shadow-sm | shadow-xs | whole shadow scale shifts down by one to make room for a new shadow-sm |
shadow | shadow-sm | same scale shift |
rounded-sm | rounded-xs | same scale-shift logic on the radius scale |
rounded | rounded-sm | same scale shift |
outline-none | outline-hidden | outline-none now means outline-style: none; the old "force-transparent" behavior is outline-hidden |
ring (3px default) | ring-3 | default ring width changed from 3px to 1px; old default is now explicit ring-3 |
blur-sm etc. | blur-xs etc. | blur scale shifts down by one (same as shadow/radius) |
Pattern: v4 introduced finer-grained
low ends on the shadow, rounded, blur,
and drop-shadow scales. To make room, every existing step
was renamed one tier down — so shadow-sm becomes
shadow-xs, shadow becomes shadow-sm,
and so on. This is the rename most likely to silently change your design
if you skip the codemod.
3 · the automated upgrade tool
The official codemod rewrites your config, CSS, and templates in place. Commit a clean tree first — it edits files on disk and you'll want to review the diff before keeping anything.
What it does for you: package swap, config → @theme
token migration, directive rewrite, every utility rename in the table
above, and the content:[] → auto-detection shift. What it
can not decide for you: whether a now-currentColor
border was a design choice or an accident, and any custom PostCSS / Vite
wiring outside Tailwind itself.
npm install tailwindcss@latest @tailwindcss/postcss. Swap the PostCSS plugin from tailwindcss to @tailwindcss/postcss.npx @tailwindcss/upgrade — interactive prompt; it walks the config + every source file.git diff — especially border colors, the shadow/radius scale shift, and dark mode.@theme; otherwise keep it and bridge with @config "./tailwind.config.js";.4 · killer gotchas — what the codemod can't catch
border now uses currentColor.
In v3 an unqualified border rendered gray-200; in v4 it
inherits the element's text color. The codemod does NOT add a color — it
can't tell whether the gray was intentional. Fix: grep for
\bborder\b in templates and add border-gray-200
(or whatever the design calls for) where the color mattered.
shadow / rounded / blur scales shifted down one tier.
shadow-sm is now smaller than it was — the codemod
renames mechanically, but if you had hand-tuned shadow values, the visual
will subtly change. Fix: spot-check hero sections and cards after
the upgrade.
ring default width changed from 3px to 1px.
A bare ring in v3 drew a 3px ring; in v4 it's 1px. The
codemod rewrites it to ring-3 to preserve the width — but
if you relied on the default without writing ring
(e.g. focus styles from a UI lib), the ring gets thinner. Fix:
audit focus rings across forms and buttons.
outline-none changed meaning.
In v3 it set a transparent 2px outline (forced, for accessibility during
forced-colors mode); in v4 outline-none sets
outline-style: none (the literal CSS meaning). The old
behavior moved to outline-hidden. Fix: the codemod
rewrites this, but verify keyboard focus is still visible.
md:hover: still means "hover within the md breakpoint", but
in v3 the rightmost variant was applied first; in v4 the leftmost is.
For 2-variant stacks this is identical, but for 3+ variants
(focus:hover:md:) the order matters. Fix: review any
3+ variant stacks; the codemod does not rewrite them.
@config bridge is a temporary crutch.
If you keep tailwind.config.js and bridge with
@config "./tailwind.config.js";, plugins and presets loaded
via JS still work — but new v4 features (@theme inline,
CSS-only @utility) won't see those tokens. Fix: use
the bridge to ship the migration, then move tokens into
@theme in a follow-up PR.
intent → pattern (cheat sheet)
| intent | v3 pattern | v4 pattern |
|---|---|---|
| import Tailwind | @tailwind base; @tailwind components; @tailwind utilities; | @import "tailwindcss"; |
| define a brand color | theme.extend.colors.brand = "#06b6d4" | @theme { --color-brand: #06b6d4; } |
| class-based dark mode | darkMode: "class" | @custom-variant dark (&:where(.dark, .dark *)); |
| scan extra source paths | content: ["./src/**/*"] | @source "../src"; (auto-detect by default) |
| linear gradient | bg-gradient-to-r from-cyan-400 to-blue-500 | bg-linear-to-r from-cyan-400 to-blue-500 |
| grow / shrink | flex-grow / flex-shrink | grow / shrink |
| 50% opacity black text | text-black opacity-50 | text-black/50 |
| visible focus ring, 3px | ring | ring-3 |
| small shadow | shadow-sm | shadow-xs (or shadow-sm for the new small) |
| keep a legacy config | (n/a) | @config "./tailwind.config.js"; at top of CSS |