Tailwind v4's headline change is that tailwind.config.js is gone. Configuration now lives in your CSS file.
That sounds like a stylistic preference and it is not. It changes where your design tokens live, how they reach the browser, and — this is the part that catches people — how Tailwind decides which files to scan for class names. Get that last one wrong and parts of your site silently render unstyled.
We migrated this site to v4. Here is the model, and the footgun.
The new mental model
v3: a JavaScript config file defined your theme. Tailwind read it at build time and generated CSS. Your design tokens existed in JavaScript, and only the utilities you used reached the browser.
v4: your CSS file is the config. Tokens are declared as CSS custom properties inside an @theme block, and they are emitted as real CSS variables.
@import 'tailwindcss';
@theme {
--color-brand: oklch(0.62 0.19 265);
--font-display: 'Inter', sans-serif;
--spacing-gutter: 1.5rem;
}That generates bg-brand, text-brand, font-display, p-gutter and the rest — and it leaves --color-brand available as a live CSS variable at runtime.
The second half is the actually useful part. In v3, theme values were compile-time only: to change a colour at runtime you regenerated CSS or maintained a parallel set of variables by hand. In v4 the variable is simply there, so runtime theming — a colour picker, a light/dark switch, a per-tenant palette — stops needing a build step.
This site has seven switchable themes and a light/dark toggle, both driven by swapping two variables on <html>. Under v3 that meant maintaining tokens in two places. Under v4 there is one place.
What actually changed
@theme replaces the config object. Design tokens as CSS variables.
@source replaces content. This tells Tailwind which files to scan. It has automatic detection now, which works for a normal single-app project and is exactly where the trouble starts in anything else. See below.
@custom-variant replaces variant plugins. Class-based dark mode becomes one line:
@custom-variant dark (&:where(.dark, .dark *));@utility replaces the plugin API for custom utilities.
Native cascade layers, so Tailwind's own layers no longer fight your CSS in surprising ways.
A faster engine. Full builds are several times quicker and incremental rebuilds are close to instant. On a large project this is the change you feel hourly.
Modern colour by default. The default palette moved to OKLCH, which gives more vivid colours on modern displays. Your old hex values still work — but a palette that looked balanced in v3 may look slightly different beside the new defaults.
The @source footgun
This is the one that cost us an afternoon, and it is the reason this article exists.
@source globs are resolved relative to the CSS file, not to the project root. In a single-app project the automatic detection handles everything and you never think about it. In a monorepo — or anywhere the stylesheet does not sit at the root of the code it styles — you must be explicit, and getting it wrong fails in a way that does not look like a configuration error at all.
Our stylesheet lives in a shared package and is imported by six separate apps. The globs have to reach both:
@import 'tailwindcss';
/* Relative to THIS FILE, not the project root. */
@source '../**/*.{js,ts,jsx,tsx}'; /* the shared package */
@source '../../site/**/*.{js,ts,jsx,tsx}'; /* every app */Write @source './components' instead — the intuitive thing — and it resolves relative to the stylesheet's own directory, finds nothing, and Tailwind silently stops generating the classes those files use.
The symptom is genuinely confusing: your page content looks fine, and the header, footer and every shared component render completely unstyled. Nothing errors. Nothing warns. The build succeeds. It looks like a broken import or a CSS load-order problem, and you will look everywhere except the one line that caused it.
Two rules to save yourself:
- Globs are relative to the CSS file. Say it out loud while writing them.
- Restart the dev server after editing
@source. These are read at startup, and a stale watcher will convince you the fix did not work.
Upgrading from v3
The official codemod handles most of it:
npx @tailwindcss/upgrade@latestIt migrates your config into @theme, updates renamed utilities, and rewrites the imports. What it does not do:
Renamed utilities. Several changed. The common ones:
v3 | v4 |
|---|---|
|
|
|
|
|
|
|
|
|
|
Note the shift: the old unsuffixed names now mean what -sm used to. Miss one and you get a subtly different shadow rather than a missing one, which is harder to spot.
The PostCSS setup. Tailwind is now its own plugin and no longer needs autoprefixer or postcss-import:
export default { plugins: { '@tailwindcss/postcss': {} } }Plugins. Anything using the v3 JavaScript plugin API needs a v4-compatible version or rewriting as @utility.
Browser support. v4 targets Safari 16.4+, Chrome 111+ and Firefox 128+, because it uses cascade layers, @property and color-mix(). If you must support older browsers, stay on v3.7 — this is the one genuine blocker.
Where v4 is a real improvement
Runtime theming becomes trivial. Tokens are live CSS variables, so a theme switch is a class on <html>:
:root { --primary: oklch(0.62 0.19 265); }
.theme-teal { --primary: oklch(0.70 0.13 195); }Every utility using --primary follows, with no rebuild and no JavaScript beyond toggling a class.
color-mix() in utilities. Opacity variants now compose with your variables, so bg-primary/10 works on a runtime-set colour — which it could not in v3.
One source of truth for tokens. Designers can read the CSS file. Nobody has to keep a JavaScript object and a stylesheet in step.
The build is fast enough to stop noticing.
Where it is worse
Being honest about the trade:
- JavaScript configuration is genuinely gone. If you generated theme values programmatically — pulling brand colours from a design-token JSON, say — that pattern needs rethinking.
- The plugin ecosystem lagged, as it always does across a major version.
- The browser floor is real. For most audiences this is fine; for some it is not, and you should check your analytics rather than assume.
- CSS-in-CSS config is less discoverable. Editor autocomplete for a JS object was good. Finding your token definitions now means knowing which file to open.
Should you upgrade?
Yes if: you are starting a project, you want runtime theming, your build times hurt, or you are already on v3.4+ with few plugins.
Wait if: you must support older browsers, you depend on an unmigrated plugin, or you generate config from JavaScript.
For a typical application the migration is an afternoon, and most of that is checking the renamed utilities rather than fighting the new model. If it is not your afternoon to spend, this is the kind of work we do if you would rather hand it over.
Conventions worth adopting with v4
The new model rewards a few habits that were optional before.
Put semantic tokens in @theme, not raw colours. Define --color-primary and --color-surface, not --color-indigo-600. Utilities then read as intent — bg-primary — and rebranding is a change to two lines rather than a search across every component. This is the same argument design systems have always made; v4 is the first version where it costs nothing.
Keep @source explicit in anything that is not a single app. Automatic detection is convenient and it is exactly what fails silently in a monorepo. Writing the globs out is one line and it is the difference between a five-minute problem and an afternoon.
Use @utility sparingly. The temptation with a friendlier extension API is to invent utilities. Most of the time a component is the right abstraction — a Button component, not a .btn utility — because a component can carry structure, state and accessibility, and a utility can only carry declarations.
Let color-mix() do the opacity work. bg-primary/10 now composes with a runtime-set variable, so you rarely need a second token for a tint. That removes a whole category of nearly-identical colour definitions.
Restart the dev server after touching the CSS config. @source and @theme are read at startup. More than one person has "fixed" a config, seen no change, reverted the fix, and gone looking somewhere else entirely.
None of these is required. All of them make the difference between v4 being a migration you completed and v4 being a system you are getting something out of.
What v4 does not change
Worth saying, because "the config file is gone" makes the release sound larger than it is.
The utilities are the same. flex, grid, p-4, text-lg, every responsive and state variant — unchanged. Your existing markup keeps working, aside from the handful of renames listed above. This is not a new framework wearing the same name.
Arbitrary values still work. w-[347px], bg-[#1a1a1a], grid-cols-[1fr_2fr] — same syntax, same behaviour.
The core criticism still applies. If you disliked long class strings in v3 you will dislike them in v4. Nothing about the CSS-first config addresses that, and it was never meant to.
Dynamic class names still do not work. This is worth repeating because it catches people in every version: Tailwind scans your source as text. It never executes it. So ` bg-${colour}-500 produces nothing, because the string bg-red-500` never appears anywhere for the scanner to find. Write complete class names and select between them:
const tone = { red: 'bg-red-500', blue: 'bg-blue-500' }[colour]That constraint has bitten this codebase before, in a comparison table that built 'grid-cols-' + count and collapsed the moment it needed a column count nobody had used elsewhere. The engine changed in v4; this did not.
Frequently asked questions
Do I still need tailwind.config.js in v4? No. Configuration moved into CSS via @theme, @source, @custom-variant and @utility. A JS config is still supported for compatibility, but it is not the intended path.
Why are my shared components unstyled after upgrading? Almost certainly @source. The globs resolve relative to the CSS file, not the project root, so a stylesheet in a shared package needs explicit globs reaching both the package and the apps. Nothing errors when they are wrong — the classes simply are not generated. Restart the dev server after fixing them.
Is Tailwind v4 faster? Substantially. Full builds are several times quicker and incremental rebuilds are close to instant. On a large codebase this is the most noticeable day-to-day change.
Can I use CSS variables with Tailwind v4? That is the core of the new model. @theme tokens are emitted as real CSS variables, so they can be changed at runtime and utilities follow automatically — which is what makes runtime theming straightforward.
What browsers does Tailwind v4 support? Safari 16.4+, Chrome 111+, Firefox 128+. It depends on cascade layers, @property and color-mix(). Older support means staying on v3.
Is the migration hard? Usually an afternoon. Run npx @tailwindcss/upgrade@latest, fix the renamed utilities, update the PostCSS config, and check any plugins. The shadow and rounded renames are the ones most likely to slip through, because they change appearance subtly rather than breaking.

.webp&w=128&q=75)
.webp&w=256&q=75)