Skip to content

Tailwind CSS v4 migration guide: what actually changed

Tailwind CSS v4 migration guide: what actually changed

I put off migrating to Tailwind v4 for about three months. The blog posts announcing it were full of phrases like “CSS-first configuration” and “native cascade layers” and I kept thinking yeah, I’ll get to that after I finish this sprint. Then I started a new Next.js project last month and figured I’d just… try it. Two hours later I was done, and I was annoyed I hadn’t done it sooner.

Here’s the actual migration path I took, with real code diffs, so you don’t have to piece it together from five different docs pages.

The config file is gone (mostly)

This is the biggest change and the one that tripped me up conceptually. In Tailwind v3, everything lived in tailwind.config.js. Your colors, your fonts, your breakpoints, your content paths. In v4, most of that moves into your CSS file.

Here’s what a typical v3 config looked like:

// tailwind.config.js (v3)
module.exports = {
  content: ['./src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {
      colors: {
        brand: '#3b82f6',
        surface: '#1e293b',
      },
      fontFamily: {
        sans: ['Inter', 'sans-serif'],
      },
    },
  },
  plugins: [require('@tailwindcss/typography')],
}

And here’s the v4 equivalent, which lives in your CSS:

/* app.css (v4) */
@import "tailwindcss";
@plugin "@tailwindcss/typography";

@theme {
  --color-brand: #3b82f6;
  --color-surface: #1e293b;
  --font-sans: "Inter", sans-serif;
}

That’s it. No JavaScript config. No content array either, because v4 auto-detects your template files. The @theme directive maps directly to CSS custom properties, which means your design tokens are actual CSS variables that you can inspect in DevTools.

I genuinely didn’t believe the content detection worked until I tested it. It scans your project for files that look like templates and just handles it. If you have an unusual setup, you can still configure it manually, but I haven’t needed to.

The new import replaces the old directives

Tailwind CSS v4 migration guide: what actually changed

In v3, you had three separate directives at the top of your CSS:

/* v3 */
@tailwind base;
@tailwind components;
@tailwind utilities;

v4 replaces all of that with a single import:

/* v4 */
@import "tailwindcss";

Under the hood, Tailwind now uses native CSS cascade layers (@layer) instead of its own synthetic layer system. This matters because it plays nicer with other CSS you might have. In v3 I’d occasionally fight specificity issues when mixing Tailwind with custom CSS. Those fights are mostly over now because the browser’s cascade layer resolution handles the ordering.

One gotcha: if you’re using PostCSS imports, the order matters. The @import "tailwindcss" line needs to come before your custom styles, same as before. But if you forget, the error messages are actually helpful now instead of the cryptic “unknown at-rule” warnings v3 used to throw.

Container queries are built in

This was the feature that made me genuinely excited. In v3, container queries needed a plugin (@tailwindcss/container-queries). In v4, they’re native.

Here’s how a responsive card component looked in v3 with the plugin:

<!-- v3 with plugin -->
<div class="@container">
  <div class="@lg:flex @lg:items-center">
    <img class="@lg:w-48" src="/photo.jpg" />
    <div class="@lg:ml-4">
      <h3>Card title</h3>
      <p>Card description</p>
    </div>
  </div>
</div>

In v4, the syntax is the same, but you don’t install anything extra. It just works. And you get named containers too:

<!-- v4 native -->
<div class="@container/card">
  <div class="@lg/card:flex @lg/card:items-center">
    <img class="@lg/card:w-48" src="/photo.jpg" />
    <div class="@lg/card:ml-4">
      <h3>Card title</h3>
      <p>Card description</p>
    </div>
  </div>
</div>

Named containers let you target specific ancestors instead of just the nearest one. If you’ve been building component libraries, this is a big deal. I’ve already replaced three or four useMediaQuery hooks with container queries, and the components are less JavaScript and more CSS, which is how it should be.

Speed is noticeably different

Tailwind v4 uses Lightning CSS instead of PostCSS for the heavy lifting. On my project (about 340 components), the initial build went from ~1.8 seconds to ~0.3 seconds. Hot reloads feel instant in a way they didn’t before.

The numbers will vary depending on your project size, but the architecture change is real. Lightning CSS is written in Rust and does parsing, prefixing, and minification in one pass. In v3, those were separate PostCSS plugins that each walked the entire AST.

I used to keep the Tailwind JIT watcher running in a separate terminal and occasionally it’d lag behind my saves. That doesn’t happen anymore. It’s a small thing, but small things add up when you’re iterating on UI all day.

What the migration actually looks like step by step

Here’s the process I followed. It took me about two hours on a medium Next.js project.

  1. Update the package. npm install tailwindcss@latest and remove postcss and autoprefixer from your dependencies if Tailwind was the only thing using them. v4 bundles its own CSS processing.

  2. Move your theme to CSS. Take each extend key from your config and convert it to a CSS variable under @theme. Colors become --color-*, fonts become --font-*, spacing becomes --spacing-*. The official migration tool handles most of this automatically.

  3. Replace the directives. Swap the three @tailwind lines for @import "tailwindcss".

  4. Move plugins to @plugin. Each require() in your config becomes an @plugin rule in CSS.

  5. Delete the config file. Seriously, just delete it. If something breaks, you’ll know what to add back.

  6. Test your build. Run your dev server. Check for missing styles. In my case, I had two custom utilities that needed updating because they used the old addUtilities API. The v4 plugin docs explain the new approach.

The official upgrade tool (npx @tailwindcss/upgrade) does steps 2 through 4 automatically. I ran it and then cleaned up by hand. It got about 90% right.

Things that caught me off guard

A few gotchas I ran into that the release notes kind of glossed over:

Default border color changed. In v3, border gave you a gray border by default. In v4, it’s currentColor. If you have buttons or cards that relied on the implicit gray, they’ll look different. Quick fix: add border-gray-200 explicitly.

Some renamed utilities. blur-sm became blur-xs, rounded-sm became rounded-xs, and a few others shifted. The upgrade tool handles these, but if you’re searching your codebase for old class names, grep for the v3 names and replace them.

Custom variants work differently. If you wrote custom variants using the addVariant plugin API, check the new syntax. The function signature changed and the old format throws at build time, so at least it fails loudly.

None of these were hard to fix. The whole migration was maybe two hours, and most of that was me reading docs to understand why things changed, not actually changing code.

Is it worth migrating an existing project?

Honestly, yes, if you’re actively working on the project. The build speed alone pays for the migration time within a week. The CSS-first config is easier to reason about, and container queries without a plugin removed real complexity from my component code.

If you have a project that’s in maintenance mode and nobody touches the CSS, leave it. The migration isn’t painful, but it’s not free either, and v3 still works fine.

I’ve been writing about developer tools and workflows on my portfolio for a while now, and Tailwind v4 is one of the few upgrades where I can point to concrete, measurable improvements rather than just “it feels nicer.” The build times are faster, the config is simpler, and container queries are a genuine addition to how I structure components.

If you’ve been putting it off like I was, set aside a couple hours this week and just do it. You’ll be annoyed you waited.

For a different kind of deep dive, I recently wrote about how LLMs know when they’re hallucinating – turns out the internals are just as interesting as the outputs.