All posts
CSSJun 11, 2026 · 5 min read

Migrating to Tailwind v4: From Config File to CSS

What actually changes when Tailwind moves its source of truth from tailwind.config.ts into the CSS file itself, and how I migrated this site without breaking a single class string.

Migrating to Tailwind v4: From Config File to CSS
On this page

A few months ago I renamed every color on this site so the design system would stop lying about what fun-pink was. Last weekend Tailwind returned the favor and deleted my config file. I migrated this site from Tailwind v3 to v4, and the short version is: every class name survived, the config file didn't, and the two bugs that actually mattered never showed up in the build output.

The config file is gone

Tailwind v4 is CSS-first. The tokens that used to live in tailwind.config.ts now live in your stylesheet, inside a @theme block:

@import "tailwindcss";
 
@theme {
  --color-accent: #00c7ff;
  --color-surface: #192742;
  --color-muted: #7b89a8;
}

Each --color-* variable generates the matching utilities — bg-accent, text-accent, border-accent, and so on. Same idea for --font-*, --animate-*, and a handful of other namespaces. In v3, the config was a TypeScript program that produced CSS. In v4, the CSS is the program.

The old JS config still works behind an @config escape hatch, but the whole point of migrating is to not need it. I deleted mine.

The boring parts went fine

Most of the migration was less dramatic than I expected. The PostCSS plugin moved to its own package, so postcss.config shrank to a single entry (@tailwindcss/postcss) and autoprefixer got deleted — v4 handles prefixing itself. The three old @tailwind directives became one @import "tailwindcss". Then I copied my eleven custom colors, two font stacks, and one animation out of theme.extend into @theme, and pasted my typography overrides into a .prose block at the bottom of the same file. The plugin loads with one line too:

@plugin "@tailwindcss/typography";

Not a single class string in JSX changed. Prettier re-sorted some class lists and that was almost the entire diff.

I also found @tailwindcss/forms in the old config, imported and consumed by exactly nothing — my inputs style themselves with plain utilities. Same lesson as the Next.js 16 migration: when you're upgrading anyway, deleting a dependency is often easier than porting it.

The two bugs the build never mentioned

Here's the part worth writing down. After the port, the build was green, every route was static, and the site was subtly broken in two ways.

My font fallback chain vanished

The blog typography felt off after the migration — not broken, just slightly wrong in a way I couldn't name. I eventually dumped the computed styles in the browser and found the body font resolving to "Be Vietnam Pro", "Be Vietnam Pro Fallback" and nothing else.

In v3, theme.fontFamily.sans carried the whole default chain for every element. In v4, the default font is driven by a separate token, --default-font-family, and it defaults to just var(--font-sans) — my Inter, system-ui, sans-serif fallbacks were silently gone. The fix is one line:

@theme {
  --default-font-family: var(--font-sans), Inter, system-ui, sans-serif;
}

A custom utility compiled to nothing

My project cards used a will-change-projectCard utility, generated in v3 from theme.extend.willChange. It turns out willChange is not one of v4's @theme namespaces — and a variable in a namespace v4 doesn't know produces zero CSS. No error, no warning, just a class that no longer exists. The hover animation got measurably jankier and nothing in any log said why.

The v4 way to register a one-off utility is explicit:

@utility will-change-projectCard {
  will-change: border-color, opacity, transform;
}

Both bugs share a shape: the migration compiles long before it's actually done. The only way I caught either one was clicking through every page in a real browser and comparing against screenshots I'd taken before starting.

What surprised me

ExpectationReality
The official codemod would do it allIt parks your old config behind @config; the real port to @theme is manual
Class names would changeNot one did — the set of classes per element is byte-identical
My prose theme would need a rewriteIt was a copy-paste into a .prose block
The CSS output would growIt shrank ~25%, from 50 KB to 37 KB raw (7.9 KB gzipped), and builds got slightly faster

Was it worth it?

Yes — and not for the speed. What I actually gained is the shape of the config: my design system is now one CSS file, with the tokens sitting next to the rules that consume them. There's no config language anymore, just CSS variables and selectors.

If you're on v3 and the config file has started to feel heavy, the jump is a weekend. Do it in small commits so a breakage points at one change. Don't trust a green build to tell you you're finished — open every page. And check --default-font-family before your typography quietly loses its fallbacks like mine did.