IAMUVIN

UI/UX & Design Engineering

Tailwind CSS v4: Everything That Changed and How to Migrate

Uvin Vindula·January 13, 2025·12 min read

Last updated: April 14, 2026

Share

TL;DR

Tailwind CSS v4 is the biggest shift the framework has ever made. The JavaScript config file is gone. Everything lives in CSS now — your theme, your tokens, your customizations — all defined with @theme and native CSS custom properties. Container queries are first-class. The new engine is 5-10x faster. I migrated four production projects (iamuvin.com, EuroParts Lanka, uvin.lk, FreshMart) from v3 to v4, and the result is cleaner code, faster builds, and a design token system that actually works across frameworks. The migration isn't painless — darkMode: 'class' is gone, @apply behaves differently, and your entire tailwind.config.js needs to be rewritten as CSS. This guide covers exactly how I did it, what broke, what got better, and the patterns I now use on every project.


What Changed in v4

Tailwind CSS v4 is not an incremental update. It's a ground-up rewrite of how the framework works. If you've been writing tailwind.config.js files for the last three years, prepare to unlearn some habits.

Here's the short version of what changed:

No more JavaScript config. The tailwind.config.js file is replaced entirely by CSS-based configuration using @theme directives. Your theme lives in your stylesheet now, right next to your styles.

CSS-first architecture. Tailwind v4 leans into the platform. CSS custom properties are the foundation of the design token system. This isn't a wrapper around CSS — it *is* CSS.

New engine (Oxide). The build engine was rewritten in Rust. Builds that took 300ms in v3 now take 30ms. On large projects like EuroParts Lanka with 200+ components, the difference is noticeable on every save.

Container queries built in. No more @tailwindcss/container-queries plugin. Container queries are native utilities now, and they change how I think about component-level responsiveness.

Automatic content detection. You no longer need a content array. Tailwind v4 automatically scans your project for class usage. One less thing to misconfigure.

New color palette. The default color scale has been refined, particularly in the middle ranges where v3 colors looked muddy in dark mode. The new defaults use OKLCH internally for perceptual uniformity.

The philosophy shift matters more than any single feature. Tailwind v4 stops pretending CSS is a second-class citizen and instead extends CSS directly. As someone who writes CSS every day for client projects, this is the right direction.


CSS-First Configuration with @theme

This is the change that will require the most work during migration. Your entire tailwind.config.js gets replaced by @theme blocks in your CSS.

Here's what the old v3 config looked like on iamuvin.com:

javascript
// tailwind.config.js (v3 — DELETED)
module.exports = {
  darkMode: 'class',
  content: ['./src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {
      colors: {
        brand: {
          primary: '#F7931A',
          accent: '#FFA940',
          deep: '#E07B0A',
          gold: '#FFB84D',
        },
        surface: {
          base: '#0A0E1A',
          card: '#111827',
          elevated: '#1A2236',
        },
      },
      fontFamily: {
        jakarta: ['Plus Jakarta Sans', 'sans-serif'],
        inter: ['Inter', 'sans-serif'],
        mono: ['JetBrains Mono', 'monospace'],
      },
      animation: {
        'fade-in': 'fadeIn 0.4s ease-out',
      },
    },
  },
};

Here's what it looks like now in v4:

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

@theme {
  /* ── Brand Colors ─────────────────────────── */
  --color-brand-primary: #F7931A;
  --color-brand-accent: #FFA940;
  --color-brand-deep: #E07B0A;
  --color-brand-gold: #FFB84D;

  /* ── Surfaces ─────────────────────────────── */
  --color-surface-base: #0A0E1A;
  --color-surface-card: #111827;
  --color-surface-elevated: #1A2236;

  /* ── Text ──────────────────────────────────── */
  --color-text-primary: #FFFFFF;
  --color-text-secondary: #C9D1E0;
  --color-text-muted: #6B7FA3;

  /* ── Semantic ──────────────────────────────── */
  --color-success: #00C97B;
  --color-danger: #FF4560;
  --color-warning: #F7931A;
  --color-info: #4A9EFF;

  /* ── Typography ────────────────────────────── */
  --font-jakarta: "Plus Jakarta Sans", sans-serif;
  --font-inter: "Inter", sans-serif;
  --font-mono: "JetBrains Mono", monospace;

  /* ── Spacing Scale ─────────────────────────── */
  --spacing-18: 4.5rem;
  --spacing-88: 22rem;
  --spacing-128: 32rem;

  /* ── Animation ─────────────────────────────── */
  --animate-fade-in: fade-in 0.4s ease-out;

  /* ── Breakpoints ───────────────────────────── */
  --breakpoint-xs: 475px;
}

@keyframes fade-in {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}

The naming convention matters. --color-* maps to bg-*, text-*, and border-* utilities automatically. So --color-brand-primary gives you bg-brand-primary, text-brand-primary, and border-brand-primary out of the box. Same pattern: --font-* maps to font-*, --spacing-* extends the spacing scale, --animate-* maps to animate-*.

This was the moment I realized v4 is genuinely better. In v3, I had to constantly cross-reference my tailwind.config.js with my CSS. Now everything is in one place. When I'm editing a component's styles, the design tokens are in the same file (or a CSS import away). No context switching between JavaScript and CSS.


Design Tokens as CSS Variables

The real power of v4's CSS-first approach is that your design tokens are native CSS custom properties. This means they work everywhere — not just in Tailwind utility classes.

On iamuvin.com, I define a complete token system:

css
/* tokens/colors.css */
@theme {
  --color-brand-primary: oklch(0.75 0.18 60);
  --color-brand-accent: oklch(0.80 0.15 65);
  --color-brand-deep: oklch(0.65 0.19 55);
  --color-brand-gold: oklch(0.83 0.13 70);
}

I switched to OKLCH values because they produce more perceptually uniform color scales. The hex values I listed earlier are the fallbacks — OKLCH is the source of truth.

These tokens work in three contexts simultaneously:

1. Tailwind utilities — use them as class names:

jsx
<button className="bg-brand-primary text-text-primary hover:bg-brand-deep">
  Get Started
</button>

2. Arbitrary values — reference them in brackets:

jsx
<div className="shadow-[0_4px_24px_var(--color-brand-primary)/0.15]">
  Glowing card
</div>

3. Raw CSS — use them directly in stylesheets or inline styles:

css
.custom-gradient {
  background: linear-gradient(
    135deg,
    var(--color-brand-primary),
    var(--color-brand-accent)
  );
}

This eliminates the biggest pain point of v3: keeping your Tailwind config, CSS variables, and component styles in sync. In v4, there's one source of truth and everything reads from it.

For dark mode, I override tokens at the selector level:

css
@theme {
  --color-surface-base: #FFFFFF;
  --color-surface-card: #F9FAFB;
  --color-text-primary: #0A0E1A;
}

.dark {
  --color-surface-base: #0A0E1A;
  --color-surface-card: #111827;
  --color-text-primary: #FFFFFF;
}

Components don't need conditional dark mode classes. They reference the token, and the token changes based on context. I wish I had this pattern three years ago — it would have saved me hundreds of dark: prefixes across every project.


Container Queries

Container queries are the feature I didn't know I needed until I started using them. In v3, responsive design was always relative to the viewport. In v4, components can respond to their container's size.

Here's a real example from FreshMart. I have a product card component that appears in three contexts: a full-width grid, a sidebar widget, and a modal. In v3, I needed three variants or complex parent-aware class names. In v4:

jsx
function ProductCard({ product }: { product: Product }) {
  return (
    <div className="@container">
      <article className="flex flex-col @md:flex-row @md:items-center gap-4">
        <img
          src={product.image}
          alt={product.name}
          className="w-full @md:w-32 @md:h-32 rounded-lg object-cover"
        />
        <div className="flex-1">
          <h3 className="text-base @lg:text-lg font-jakarta font-semibold">
            {product.name}
          </h3>
          <p className="text-text-muted text-sm @lg:text-base mt-1">
            {product.description}
          </p>
          <span className="text-brand-primary font-bold mt-2 block">
            {product.price}
          </span>
        </div>
      </article>
    </div>
  );
}

The @container class establishes a containment context. Then @md: and @lg: prefixes respond to the container width instead of the viewport. The same component works in a 400px sidebar and a 1200px main content area without any changes.

Container query breakpoints in v4 follow a similar scale to viewport breakpoints:

PrefixMin Width
@xs320px
@sm384px
@md448px
@lg512px
@xl576px
@2xl672px

You can also define custom container breakpoints in your @theme:

css
@theme {
  --container-3xl: 800px;
  --container-4xl: 960px;
}

I've started using container queries for every component that might exist in multiple layout contexts. It makes components genuinely portable — I can move a card from a three-column grid to a sidebar without touching its internals.


New Utilities and Features

v4 ships a batch of new utilities that eliminate common workarounds.

`text-wrap-balance` and `text-wrap-pretty` — I've been waiting for these. Headlines that wrap unevenly look amateur. Now I add one class:

jsx
<h1 className="text-4xl font-jakarta font-bold text-wrap-balance">
  Building the Future of Sri Lankan E-Commerce
</h1>

text-wrap-balance distributes words evenly across lines. text-wrap-pretty prevents orphans (single words on the last line). I use balance for headlines and pretty for body paragraphs on iamuvin.com.

`size-*` utility — combines width and height in one class. I used to write w-10 h-10 everywhere. Now it's size-10. Small improvement, but it adds up across a project with hundreds of avatar and icon instances.

Scroll-driven animations — Tailwind v4 includes utilities for the CSS animation-timeline property:

jsx
<div className="animate-fade-in supports-[animation-timeline]:animation-timeline-scroll">
  Content that fades in on scroll
</div>

`inset-shadow-*` and `inset-ring-*` — new shadow utilities for inner shadows and ring effects. The EuroParts Lanka search input uses an inset shadow for a recessed look:

jsx
<input
  className="w-full rounded-xl bg-surface-card px-4 py-3 text-text-primary
             inset-shadow-sm border border-white/5
             focus:inset-shadow-md focus:border-brand-primary/30
             transition-all duration-200"
  placeholder="Search parts..."
/>

`not-*` variant — the negation pseudo-class is finally a first-class variant. Instead of complex sibling selectors:

jsx
<li className="not-last:border-b border-white/10 py-3">
  {item.name}
</li>

These aren't revolutionary individually, but collectively they eliminate dozens of custom CSS rules I used to maintain alongside Tailwind.


Migration from v3 to v4

Here's the migration process I used across four projects. I'll be honest — it's not a one-hour job on any non-trivial codebase.

Step 1: Update Dependencies

bash
npm install tailwindcss@latest @tailwindcss/vite@latest

If you're on Next.js, update to the version that supports Tailwind v4's PostCSS plugin or Vite integration.

Step 2: Replace the Config File

Take your tailwind.config.js and translate it to @theme blocks. I wrote a mapping table for my team:

v3 Config Keyv4 CSS Token
theme.colors.brand.primary--color-brand-primary
theme.fontFamily.jakarta--font-jakarta
theme.spacing.18--spacing-18
theme.borderRadius.xl--radius-xl
theme.animation.fade--animate-fade
theme.screens.xs--breakpoint-xs

Step 3: Fix Dark Mode

The darkMode: 'class' option doesn't exist in v4. Dark mode using the dark: variant works with the CSS prefers-color-scheme media query by default. To use class-based dark mode (which I do on every project), add this to your CSS:

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

This was the gotcha that cost me an hour on the first migration. The docs mention it, but it's easy to miss if you're scanning quickly.

Step 4: Audit @apply Usage

@apply still works in v4, but there's a subtle change. It now applies utilities in the order they appear in your source CSS, not in the order Tailwind generates them. This means specificity can behave differently.

I found three components on uvin.lk where @apply overrides stopped working because a later utility in the chain had lower specificity than before. The fix was straightforward — reorder the utilities or switch to direct CSS properties:

css
/* v3 — worked */
.btn-primary {
  @apply bg-brand-primary text-white px-6 py-3 rounded-xl hover:bg-brand-deep;
}

/* v4 — same, but verify hover states still override correctly */
.btn-primary {
  @apply rounded-xl bg-brand-primary px-6 py-3 text-white hover:bg-brand-deep;
}

My rule: if an @apply block has more than five utilities, convert it to plain CSS using the design tokens instead. @apply is a convenience, not an architecture.

Step 5: Remove Plugins That Are Now Built-In

These v3 plugins are redundant in v4:

  • @tailwindcss/container-queries — native now
  • @tailwindcss/typography — included by default (the prose classes work out of the box)

Uninstall them and remove the plugins array from any remaining config.

Step 6: Test Everything

I run a visual regression test on every page after migration. Tailwind v4 changes the underlying CSS generation, so even identical class names can produce slightly different output. On EuroParts Lanka, I caught two issues:

  1. Ring utilitiesring in v3 defaulted to a 3px blue ring. In v4, ring is just ring-1 with the current color. I had to explicitly add ring-3 ring-blue-500 where I relied on the old default.
  1. Default border colorborder in v3 used gray-200. In v4, it uses currentColor. I added explicit border-gray-200 to about 15 components.

Neither was hard to fix, but they're the kind of thing that slips through if you don't look at every page.


Performance Improvements

The Oxide engine in v4 is legitimately fast. Here are the build times I measured on my actual projects:

Projectv3 Buildv4 BuildImprovement
iamuvin.com280ms35ms8x
EuroParts Lanka420ms52ms8x
uvin.lk190ms28ms7x
FreshMart350ms41ms8.5x

The difference is most noticeable in development. Hot reload feels instant now. On EuroParts Lanka, which has the largest component library, there used to be a perceptible delay between saving a file and seeing the change. That delay is gone.

The output CSS is also smaller. Tailwind v4 generates more efficient selectors and deduplicates more aggressively. iamuvin.com's production CSS went from 38KB to 29KB (gzipped) after migrating — a 24% reduction with zero effort on my part.

The automatic content detection also means no more accidental bloat from misconfigured content paths. In v3, I once had a content glob that included node_modules accidentally. The build still worked, but it was scanning 40,000 files it didn't need to. v4's detection is smarter — it scans only the files that actually import or reference your CSS.


My Tailwind v4 Setup

Here's the complete setup I use on new projects in 2026. This is the starting point for every project I build.

File Structure

src/
  styles/
    globals.css        # @import "tailwindcss" + @theme
    tokens/
      colors.css       # --color-* tokens
      typography.css   # --font-*, --text-* tokens
      spacing.css      # --spacing-*, --radius-* tokens
      animation.css    # --animate-*, @keyframes

Main Stylesheet

css
/* src/styles/globals.css */
@import "tailwindcss";
@import "./tokens/colors.css";
@import "./tokens/typography.css";
@import "./tokens/spacing.css";
@import "./tokens/animation.css";

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

/* ── Base Layer ──────────────────────────────── */
@layer base {
  html {
    font-family: var(--font-inter);
    color: var(--color-text-primary);
    background: var(--color-surface-base);
    -webkit-font-smoothing: antialiased;
    scroll-behavior: smooth;
  }

  ::selection {
    background: var(--color-brand-primary);
    color: var(--color-text-primary);
  }
}

Color Tokens

css
/* src/styles/tokens/colors.css */
@theme {
  --color-brand-primary: oklch(0.75 0.18 60);
  --color-brand-accent: oklch(0.80 0.15 65);
  --color-brand-deep: oklch(0.65 0.19 55);
  --color-brand-gold: oklch(0.83 0.13 70);

  --color-surface-base: #0A0E1A;
  --color-surface-card: #111827;
  --color-surface-elevated: #1A2236;

  --color-text-primary: #FFFFFF;
  --color-text-secondary: #C9D1E0;
  --color-text-muted: #6B7FA3;

  --color-success: #00C97B;
  --color-danger: #FF4560;
  --color-warning: #F7931A;
  --color-info: #4A9EFF;
}

Next.js Layout Integration

tsx
// app/layout.tsx
import "@/styles/globals.css";
import { Plus_Jakarta_Sans, Inter, JetBrains_Mono } from "next/font/google";

const jakarta = Plus_Jakarta_Sans({
  subsets: ["latin"],
  variable: "--font-jakarta",
});

const inter = Inter({
  subsets: ["latin"],
  variable: "--font-inter",
});

const jetbrains = JetBrains_Mono({
  subsets: ["latin"],
  variable: "--font-mono",
});

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="en"
      className={`${jakarta.variable} ${inter.variable} ${jetbrains.variable}`}
      suppressHydrationWarning
    >
      <body className="bg-surface-base text-text-primary antialiased">
        {children}
      </body>
    </html>
  );
}

Component Pattern

Here's how a card component looks using v4 patterns — container queries, design tokens, and the new utilities:

tsx
function ProjectCard({ project }: { project: Project }) {
  return (
    <div className="@container">
      <article
        className="group rounded-2xl bg-surface-card border border-white/5
                   p-5 @lg:p-8 transition-all duration-300
                   hover:border-brand-primary/20 hover:shadow-lg
                   hover:shadow-brand-primary/5"
      >
        <div className="flex flex-col @md:flex-row gap-5">
          <img
            src={project.thumbnail}
            alt={project.title}
            className="size-full @md:size-24 rounded-xl object-cover"
          />
          <div className="flex-1">
            <h3 className="font-jakarta font-bold text-lg text-wrap-balance">
              {project.title}
            </h3>
            <p className="text-text-muted text-sm mt-2 text-wrap-pretty">
              {project.description}
            </p>
            <div className="flex flex-wrap gap-2 mt-4">
              {project.tags.map((tag) => (
                <span
                  key={tag}
                  className="rounded-full bg-brand-primary/10 px-3 py-1
                             text-xs font-medium text-brand-primary"
                >
                  {tag}
                </span>
              ))}
            </div>
          </div>
        </div>
      </article>
    </div>
  );
}

Key Takeaways

The migration is worth it. Every project I've moved to v4 is faster to build, faster to compile, and easier to maintain. The initial migration cost pays for itself within the first week.

CSS-first config is better. Having your tokens in CSS instead of JavaScript eliminates a whole category of sync issues. One source of truth, readable by any tool that understands CSS.

Container queries change component design. Once you start thinking in container-relative terms instead of viewport-relative, your components become genuinely reusable. This is the biggest mental shift.

Watch for subtle breaking changes. Default ring styles, border colors, and @apply specificity order all changed. Visual regression testing is mandatory during migration.

OKLCH for color tokens. Define your colors in OKLCH within @theme for perceptually uniform scales. It produces better hover states and gradients than hex/RGB.

Keep @apply minimal. If your @apply block has more than five utilities, write plain CSS using your design tokens instead. The tokens make this easy now.

Build times matter. 8x faster builds don't just save time — they change how you work. Sub-50ms feedback loops make you more experimental with styles.

Tailwind v4 is the version that made me stop reaching for CSS Modules or styled-components on any project. The combination of CSS-native tokens, container queries, and raw performance makes it the default choice for every frontend project I ship. If you're still on v3, the migration is straightforward — just budget half a day for a medium-sized project and a full day for anything with 100+ components.


*I'm Uvin Vindula (IAMUVIN) — a Web3 and AI engineer based between Sri Lanka and the UK. I build production-grade web applications with Next.js, Tailwind CSS v4, and Supabase. If you're planning a migration or starting a new project, check out my services or reach out at contact@uvin.lk.*

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.