Skip to main content
Back to blogCopy Markdown
·Updated ·7 min read
·tailwindcsscssfrontendastrowebdev

Tailwind CSS 4: a Rust engine, rebuilds in microseconds

Tailwind CSS 4 rebuilt its engine in Rust and publishes its own measurements. What breaks migrating from v3, the @theme block and @tailwindcss/vite. →

Óscar Gallego

Software engineer · Málaga

Tailwind CSS 4 performance - Rust engine optimization illustration
On this page

Tailwind CSS 4 is not a minor update. It’s a complete rewrite of the engine in Rust, and it breaks v3 compatibility in places that will each cost you an afternoon.

Tailwind measures the speed; you will notice the breakage. Here’s what is worth knowing before you migrate.

What v4 changes, in 30 seconds

The framework was rebuilt around a new Rust-powered compiler. According to Tailwind’s v4 announcement, full builds are up to 5x faster and incremental rebuilds that add no new CSS over 100x faster. In their table of medians on Catalyst, the full build goes from 378ms to 100ms and a rebuild that compiles no new CSS goes from 35ms to 192µs. On top of that, a “CSS-first” configuration that removes the need for a tailwind.config.js file.

The pieces that matter:

  • New Rust engine: faster full builds, and incremental rebuilds that add no new CSS finish in microseconds, per the Tailwind measurements linked above.
  • CSS-based configuration: tailwind.config.js is no longer required. CSS-first is the default now, via native CSS variables and the @theme directive inside your main CSS file. A JS config still works if you load it explicitly with @config "./tailwind.config.js".
  • Native Vite integration: it plugs in directly as a Vite plugin (@tailwindcss/vite), no more framework-specific wrappers like @astrojs/tailwind.
  • CSS plugins: now imported straight into the CSS with the @plugin directive.
  • Container queries in core: what used to need a plugin is now built in.

What I did not measure

I’m not repeating my own v3 vs v4 timings here, because one run on one machine isn’t worth quoting: the performance figures in this post are Tailwind’s, with their source. If you want your number, time your build before and after on your machine.

tailwind.config.js is no longer required

The most radical change is that the JavaScript configuration file is no longer required. CSS-first is the default, and if you still want a JS config you load it explicitly with @config "./tailwind.config.js". It doesn’t get picked up automatically anymore.

Before (v3)

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: '#3b82f6',
        secondary: '#8b5cf6',
      },
      fontFamily: {
        sans: ['Inter', 'system-ui', 'sans-serif'],
      },
      spacing: {
        '128': '32rem',
      }
    }
  },
  plugins: [
    require('@tailwindcss/typography'),
    require('@tailwindcss/forms'),
  ],
}

After (v4)

/* src/styles/global.css */
@import "tailwindcss";

@theme {
  /* Custom colors */
  --color-primary: #3b82f6;
  --color-secondary: #8b5cf6;

  /* Fonts */
  --font-sans: 'Inter', system-ui, sans-serif;

  /* Custom spacing */
  --spacing-128: 32rem;

  /* Breakpoints */
  --breakpoint-3xl: 1920px;
}

/* Plugins are now native CSS */
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";

What you gain:

  1. Improved type-safety: CSS variables get better autocomplete in VSCode.
  2. Instant hot reload: theme changes apply without recompiling.
  3. Less abstraction: what you see in the CSS is what you get.

The correct Astro setup (and the one that breaks)

One thing before anything else: do not use @astrojs/tailwind with Tailwind v4. It causes conflicts.

Setup for Astro + Tailwind v4

npm install tailwindcss @tailwindcss/vite
// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  vite: {
    plugins: [tailwindcss()],
  },
});
/* src/styles/global.css */
@import "tailwindcss";
---
// src/layouts/Layout.astro
import '@/styles/global.css';
---

And the version that breaks:

// WRONG
import tailwind from '@astrojs/tailwind';

export default defineConfig({
  integrations: [tailwind()], // This breaks Tailwind v4
});

@apply inside @keyframes: the change that hurts most

The most painful change in v4: @apply does not work inside @keyframes.

The case that fails

/* WRONG: this BREAKS in Tailwind v4 */
@keyframes fade-in {
  from {
    @apply opacity-0 scale-95;
  }
  to {
    @apply opacity-100 scale-100;
  }
}

Error:

@apply is not supported within at-rules like @keyframes

The fix: vanilla CSS

/* CORRECT: use standard CSS in keyframes */
@keyframes fade-in {
  from {
    opacity: 0;
    scale: 0.95;
  }
  to {
    opacity: 1;
    scale: 1;
  }
}

/* Then apply the animation */
.fade-enter {
  animation: fade-in 0.3s ease-out;
}

The other trap: @apply with dark mode in Astro

<style>
  /* WRONG: this may fail in some cases */
  .card {
    @apply bg-white dark:bg-gray-900;
  }
</style>

Error:

The `dark:bg-gray-900` class does not exist

The fix is CSS variables plus regular classes:

<div class="card bg-white dark:bg-gray-900">
  <!-- content -->
</div>

<style>
  /* Only styles that don't depend on Tailwind */
  .card {
    border-radius: 0.5rem;
    box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  }
</style>

Five problems when migrating

1. Outdated plugins

Many v3 plugins simply don’t work in v4.

Temporary fix:

// Use the v4-compatible version
npm install @tailwindcss/typography @tailwindcss/forms

2. Custom utilities with addUtilities()

Before (v3):

// tailwind.config.js
const plugin = require('tailwindcss/plugin');

module.exports = {
  plugins: [
    plugin(({ addUtilities }) => {
      addUtilities({
        '.scrollbar-hide': {
          '-ms-overflow-style': 'none',
          'scrollbar-width': 'none',
          '&::-webkit-scrollbar': {
            display: 'none'
          }
        }
      });
    })
  ]
}

After (v4):

/* src/styles/global.css */
@layer utilities {
  .scrollbar-hide {
    -ms-overflow-style: none;
    scrollbar-width: none;

    &::-webkit-scrollbar {
      display: none;
    }
  }
}

3. Arbitrary values

v3:

<div class="w-[calc(100%-2rem)]">

v4: (Works the same, but now with better autocompletion)

<div class="w-[calc(100%-2rem)]">

4. Color opacity syntax

v3:

<div class="bg-blue-500/50">

v4: (No changes, but now more efficient)

<div class="bg-blue-500/50">

5. Container queries

Before, this required a plugin. Now it’s built into v4:

<div class="@container">
  <div class="@md:grid-cols-2">
    <!-- Adapts to container, not viewport -->
  </div>
</div>

Dark mode without the flash

Tailwind v4 improves support for class-based dark mode.

The configuration I recommend

@import "tailwindcss";

@variant dark (&:where(.dark, .dark *));

This allows:

<!-- Option 1: Class on root -->
<html class="dark">
  <div class="bg-white dark:bg-gray-900">
</html>

<!-- Option 2: Class on specific container -->
<div class="dark">
  <p class="text-gray-900 dark:text-white">
</div>

Avoid FOUC (Flash of Unstyled Content)

<script>
  // Execute BEFORE render
  if (localStorage.theme === 'dark' ||
      (!localStorage.theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
    document.documentElement.classList.add('dark');
  }
</script>

The upgrade tool does half the job

npx @tailwindcss/upgrade

Run it and it converts tailwind.config.js to @theme in your CSS, updates the imports in your files, and detects incompatible plugins. Useful.

What it does not do: migrate custom utilities (that one’s on you) or fix @apply inside keyframes (you rewrite those by hand). Budget time for both.

Tips that still hold in v4

1. Aggressive purge (no longer needed)

In v3, you had to configure purge. In v4, tree-shaking is automatic and smarter. One less thing.

2. Use CSS nesting

/* Take advantage of native CSS nesting */
.card {
  @apply rounded-lg shadow-md;

  &:hover {
    @apply shadow-xl scale-105;
  }

  & .card-title {
    @apply text-xl font-bold;
  }
}

3. Optimize fonts with CSS variables

@theme {
  --font-sans: 'Inter var', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', monospace;
}

Then use font-variation-settings for dynamic weights:

.dynamic-weight {
  font-variation-settings: 'wght' var(--font-weight);
}

Is it worth migrating now?

Yes, if:

  • Your build feels slow
  • You use Vite/Astro (better integration)
  • You’re starting a new project
  • You want to leverage native container queries

Wait, if:

  • You have many incompatible custom plugins
  • Your team can’t set aside time for the migration
  • You rely heavily on @apply in keyframes
  • You use frameworks that don’t officially support v4 yet

The migration hurts at first

What you will run into is the list above: @apply in keyframes, v3 plugins and custom utilities to rewrite. The speed gain is the one Tailwind publishes; the cost of migrating is yours.

If your build feels slow, you already know what to do with your next free afternoon.

Related reading: the setup in this post assumes Astro. If you’re still deciding on the framework itself, start with why Astro ships so little JavaScript.

Sources

  • Tailwind Labs, Tailwind CSS v4.0 (benchmarks on Catalyst, medians: full build, incremental rebuild and rebuild with no new CSS).

P.S. Migrating a v3 project and hit something this post doesn’t cover? Tell me on LinkedIn.

Last updated:

Share this article