In Tailwind v3, your design system lived in tailwind.config.js — a JavaScript object the build read once and compiled away. The values were real to the build tool and invisible to the browser. If a chart library or a canvas needed your brand colour, you either duplicated the hex code or imported resolveConfig and shipped a chunk of the theme object to the client just to read one string.
v4 inverts that. Your main CSS file becomes the single source of truth. The three @tailwind directives are gone, replaced by one import, and the old theme.extend block becomes a @theme block written in plain CSS.
// tailwind.config.js — the v3 way, soon to be deleted
module.exports = {
content: ["./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
brand: "#3b82f6",
surface: "#1a1a2e",
},
fontFamily: {
heading: ["Cal Sans", "sans-serif"],
},
},
},
plugins: [require("@tailwindcss/typography")],
};/* app/globals.css — all configuration lives here now */
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--color-brand: #3b82f6;
--color-surface: #1a1a2e;
--font-heading: "Cal Sans", sans-serif;
}The part that changes how you think: every token in @theme is emitted as a real custom property on :root. The bg-brand utility compiles to background-color: var(--color-brand). The token exists at runtime, in the browser, and anything can read it.
// Read a design token at runtime — in v3 this needed resolveConfig
const brand = getComputedStyle(document.documentElement)
.getPropertyValue("--color-brand");
// "#3b82f6" — hand it to a chart, a canvas, a motion valueCharts, canvas drawing, motion values, generated OG images — anything that needs the raw value outside a class name gets it without a second definition. This is not a preference about configuration formats. It is your design tokens moving from build-time constants to a live API.
The mapping from the old config follows a namespace convention: keys under colors become --color-* variables, fontFamily becomes --font-*, spacing becomes --spacing-*, borderRadius becomes --radius-*. The namespace decides which utilities a token generates — define --color-surface and bg-surface, text-surface, and border-surface all exist.
@theme {
/* colors.brand.primary -> --color-brand-primary */
--color-brand-primary: #3b82f6;
/* fontFamily.sans -> --font-sans */
--font-sans: "Inter", sans-serif;
/* spacing.18 -> --spacing-18 */
--spacing-18: 4.5rem;
/* borderRadius.xl -> --radius-xl */
--radius-xl: 1rem;
}v4 ships a new engine written in Rust. The Tailwind team's published numbers are full builds around five times faster and incremental rebuilds over a hundred times faster, and the second number is the one you feel. Rebuilds on this site stopped being something I wait for — save the file, and the browser already has it.
The change you will notice every day, though, is not speed. It is the death of the content array. In v3, Tailwind only generated classes it found in the files you listed, and that list was a steady source of quiet failures: add a new folder, forget to extend the glob, and a class works in development but vanishes from the production build. Write the glob too wide and the build scans node_modules instead.
v4 detects source files automatically. It walks your project, respects .gitignore, and skips binary files. When the heuristics are not enough — a component library in another workspace package, a folder you want ignored — you steer them with @source.
/* v4 scans your project automatically — no content array */
/* Pull in a workspace package it cannot see: */
@source "../../packages/ui/src";
/* Exclude a folder the heuristics pick up: */
@source not "./legacy";Autoprefixer and postcss-import leave your dependency list too. Vendor prefixing and import flattening are handled by the engine itself.
Most of your markup survives untouched. The breaking changes cluster in three places, and knowing where they are turns a scary upgrade into a boring one.
First, the renames. The sizing scales for shadows, blur, and border radius all shifted down one notch, and a handful of utilities changed names or disappeared:
- shadow-sm is now shadow-xs, and the bare shadow is now shadow-sm — the same one-notch shift applies to blur and rounded
- outline-none is now outline-hidden; a new outline-none exists that genuinely sets outline-style: none
- ring is now ring-3, because the default ring width dropped from 3px to 1px
- bg-opacity-* and text-opacity-* are gone — use the slash modifier instead, as in bg-black/50
- flex-shrink-0 and flex-grow are now shrink-0 and grow
Second, the changed defaults, which are sneakier because nothing fails. Border and divide utilities now default to currentColor instead of gray-200, and rings default to currentColor instead of blue-500. A page can compile cleanly and look subtly wrong — borders suddenly dark, focus rings the wrong colour. You find these with your eyes, not with the compiler.
Third, the toolchain. The PostCSS plugin moved into its own package, @tailwindcss/postcss, and Vite projects should switch to the dedicated @tailwindcss/vite plugin for the fastest path. This site runs the PostCSS route under Next.js, and the whole config is six lines:
// postcss.config.mjs — the entire file in v4
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};Plugins changed shape as well. JavaScript plugins load through a @plugin directive in your CSS rather than a plugins array. First-party plugins like typography work that way today, and some are simply obsolete — container queries are built into core now. Community plugins that reach into v3 internals are the real risk; check each one for v4 support before you start.
warning
After the upgrade, a leftover tailwind.config.js is silently ignored. It sits in your repo looking authoritative, you edit it, and nothing happens — no warning, no error. Delete it once migration is done, or load it explicitly with @config "./tailwind.config.js" while you transition.
The official tool does the mechanical work and does it well: it updates dependencies, swaps the @tailwind directives for the import, converts your config into a @theme block, renames utilities across your templates, and rewrites the PostCSS setup. It needs Node 20 or newer, and it edits files in place, so give it a branch of its own.
# new branch, Node 20+, then let the tool do the mechanical work
git checkout -b update/tailwind-v4
npx @tailwindcss/upgradeThen come the manual passes, which is the part people skip and regret:
- Read the entire diff before committing anything — the tool is good, but it just edited your templates
- Search for class names the tool cannot see: strings built with concatenation, classes stored in a CMS or a database, anything assembled at runtime
- Do a visual sweep of every page for the changed defaults — borders, rings, shadows, placeholder text
- Delete the leftovers: tailwind.config.js, autoprefixer, postcss-import
On a mid-sized app, expect the tool to take a minute and the manual passes to take an afternoon. That is the honest budget.
v4's dark: variant follows prefers-color-scheme by default. The moment you add a theme switcher you need class-based theming instead, and you redefine the variant once with @custom-variant. But the better pattern barely uses dark: at all. Define semantic tokens — background, foreground, muted — that flip per theme, and expose them to Tailwind with @theme inline:
@import "tailwindcss";
/* class-based theming instead of prefers-color-scheme */
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: oklch(0.99 0 0);
--foreground: oklch(0.21 0.02 260);
}
.dark {
--background: oklch(0.17 0.02 260);
--foreground: oklch(0.96 0 0);
}
/* expose the semantic names as utilities */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
}Now bg-background and text-foreground exist as utilities, and they flip with the theme automatically because they resolve to a variable the .dark class redefines. The inline keyword matters: it tells Tailwind to reference the variable directly in the generated utility instead of baking in its current value, so the lookup happens at runtime, per theme. Your markup stays clean — dark: survives for genuine one-offs, not for every surface and text colour on the page. This site pairs the pattern with next-themes, which sets the class on the html element and nothing more.
Custom utilities used to go in @layer utilities and mostly worked — until you put hover: or md: in front of one and discovered that variants did not apply. The @utility directive registers your CSS as a first-class utility, variants included.
@utility scrollbar-hidden {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}hover:scrollbar-hidden and md:scrollbar-hidden now behave like any built-in class. That single change removes most of the reasons projects accumulated a components.css nobody wanted to own.
tip
Audit your arbitrary values while you are in there. Most bracket habits from v3 — w-[4.5rem], mt-[52px], grid-cols-[repeat(16,minmax(0,1fr))] — were workarounds for a frozen scale. v4 derives spacing from a single --spacing token, so w-18 and mt-13 just work, and grid-cols-16 generates on demand. After migration, brackets should be rare enough to raise an eyebrow in code review.
v4 is the right default for new projects and for most existing ones. There are three situations where waiting is the smarter call.
If your build leans on several community plugins, check every one before touching anything. The plugin API changed, and a plugin that patches v3 internals will not survive the jump. One unported plugin you can replace by hand; three that you genuinely depend on means the migration timeline is not yours to control yet.
If your design system is mid-flight — tokens being renamed, palette under discussion — finish that first. Migrating the config format while the values inside it are churning produces diffs nobody can review and regressions nobody can bisect. One migration at a time.
And check your browser floor. v4 targets Safari 16.4, Chrome 111, and Firefox 128 as minimums, because it builds on cascade layers and modern colour functions. If your audience includes older browsers — some enterprise and government contexts still do — v3 keeps working. It will not get new features, but it does not rot either.
When you are ready, this is the whole migration in order:
- Create a branch and confirm you are on Node 20 or newer
- Run npx @tailwindcss/upgrade and read every line of the diff
- Switch the toolchain: @tailwindcss/postcss for PostCSS setups, @tailwindcss/vite for Vite
- Hunt down dynamic class names the tool cannot rewrite — concatenated strings, CMS content
- Verify each plugin has v4 support and load it with @plugin
- Move remaining theme values into @theme, then delete tailwind.config.js or wire it temporarily with @config
- Visually re-check borders, rings, shadows, and placeholders — the defaults moved
- Convert @layer utilities blocks to @utility
- Set up semantic dark-mode tokens with @theme inline
- Remove autoprefixer and postcss-import from package.json, then run a full production build
If the production app feels too risky for a first attempt, do not start there. Take a side project or a half-finished portfolio site and migrate that this week — the whole exercise fits in an evening on a small codebase, and you will hit every category of problem once, at low stakes. By the time you point the upgrade tool at something that matters, the diff will read like a checklist you have already worked through.
