I migrated three client WordPress themes and one Next.js app from Tailwind v3 to v4 over two weekends. The upgrade tool did about 85% of the work, the remaining 15% was concentrated in a predictable set of gotchas, and the build times afterwards made me wonder why I waited. This is the migration guide I would have wanted, written from the diffs.

What actually changed

  • CSS-first configuration. The JavaScript config file is gone by default. Theme customisation lives in CSS using @theme.
  • Oxide engine. A Rust-based scanner and compiler. Full rebuilds that took 800 ms now take 30 ms on the same theme. Incremental builds are effectively instant.
  • Automatic content detection. No more content: [] array. Tailwind scans everything not in .gitignore; you add @source for unusual paths.
  • Native CSS features. Cascade layers, @property, color-mix(), container queries built in. The output is modern CSS, not a polyfill.
  • Changed defaults. Border colour, ring width, outline behaviour and a few utility names changed. This is where the manual work is.

Config: before and after

// v3: tailwind.config.js
module.exports = {
  content: ['./**/*.php', './src/**/*.{js,jsx}'],
  theme: {
    extend: {
      fontFamily: { sans: ['"Google Sans Flex"', 'sans-serif'] },
      colors: { primary: '#0071e3', secondary: '#f8fafc' },
      animation: { blob: 'blob 7s infinite' },
      keyframes: {
        blob: {
          '0%, 100%': { transform: 'translate(0,0) scale(1)' },
          '33%': { transform: 'translate(30px,-50px) scale(1.1)' },
          '66%': { transform: 'translate(-20px,20px) scale(0.9)' },
        },
      },
    },
  },
  plugins: [require('@tailwindcss/typography')],
};
/* v4: src/app.css  -- the entire configuration */
@import "tailwindcss";
@plugin "@tailwindcss/typography";

@theme {
  --font-sans: "Google Sans Flex", "Google Sans", "Helvetica Neue", sans-serif;

  --color-primary: #0071e3;
  --color-secondary: #f8fafc;

  --animate-blob: blob 7s infinite;
  @keyframes blob {
    0%, 100% { transform: translate(0, 0) scale(1); }
    33%      { transform: translate(30px, -50px) scale(1.1); }
    66%      { transform: translate(-20px, 20px) scale(0.9); }
  }
}

/* Paths Tailwind's auto-detection would miss (e.g. a plugin outside the theme dir) */
@source "../../plugins/umm-blocks/src";

Those --color-primary variables are not just config; they are emitted as real custom properties on :root. That means bg-primary works as before and you can write color: var(--color-primary) in any hand-written CSS, or override it at runtime for dark mode or white-label themes. On one agency client that removed an entire SCSS variables file that had been duplicating the Tailwind palette for years.

Step 1: Run the upgrade tool

# Commit first. The tool rewrites templates and CSS in place.
git checkout -b tailwind-v4
npx @tailwindcss/upgrade
git diff --stat

It migrates the config to @theme, updates @tailwind directives to the single import, renames utilities it knows about, and swaps PostCSS or Vite plugin packages. Read the diff. On a 60-template WordPress theme it touched 41 files and got every rename right; what it cannot do is know what your design intended, which brings us to the defaults.

Step 2: The gotchas, in order of how often they bit me

Default border colour is now currentColor

In v3, border with no colour gave you gray-200. In v4 it inherits the text colour. Every card with border border-slate-200 is fine; every card with a bare border suddenly has a dark outline. Either add explicit colours (better) or restore the old default globally:

@layer base {
  *, ::before, ::after { border-color: var(--color-gray-200, currentColor); }
}

Ring and shadow defaults

ring is now 1px (was 3px) and shadow-sm/shadow shifted down a step (shadowshadow-sm, shadow-smshadow-xs). The upgrade tool renames these, but focus states that relied on the thick default ring need ring-3 explicitly. Grep for focus:ring and check each one.

Renamed utilities

v3v4
bg-opacity-50, text-opacity-*Removed; use bg-black/50 slash syntax
flex-shrink-0 / flex-growshrink-0 / grow
overflow-ellipsistext-ellipsis
decoration-slicebox-decoration-slice
outline-noneoutline-hidden (outline-none now means outline-style: none)
!important prefix: !mb-0Suffix: mb-0!
Arbitrary CSS vars: bg-[--brand]bg-(--brand)

The important modifier moved

This one silently broke a client's modal close button: !hidden became a no-op instead of hidden!. The upgrade tool handles PHP and JSX files but missed classes built in PHP strings like '!' . $utility. Grep for ! immediately before a utility.

Preflight and buttons

Buttons now default to cursor: default like native browsers. If your design expects a pointer on every button, add it back in @layer base. Placeholder text is now 50% of the current colour instead of gray-400; check forms on dark backgrounds.

Stacked variant order is now left-to-right

first:*:pt-0 used to apply right-to-left. It now applies in reading order, so a few nested-selector utilities flip meaning. The tool rewrites the ones it can detect; verify anything using *: or **:.

Step 3: Wiring v4 into a WordPress theme with Vite

If you are still loading the Tailwind CDN script in production, this is the moment to stop. The CDN build is meant for prototyping: it ships the whole engine to every visitor and generates CSS at runtime, which hurts LCP and INP. The v4 Vite plugin makes the proper setup a five-minute job.

cd wp-content/themes/umm-theme
npm init -y
npm install -D vite @tailwindcss/vite tailwindcss
// vite.config.js
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  plugins: [tailwindcss()],
  build: {
    outDir: 'dist',
    emptyOutDir: true,
    manifest: true,                                       // hashed filenames for cache busting
    rollupOptions: { input: ['src/app.css', 'src/app.js'] },
  },
});
<?php
// functions.php -- enqueue the hashed build output via the Vite manifest
add_action('wp_enqueue_scripts', function (): void {
    $manifest_path = get_theme_file_path('dist/.vite/manifest.json');
    if (!file_exists($manifest_path)) return;

    $manifest = json_decode((string) file_get_contents($manifest_path), true);
    $css = $manifest['src/app.css']['file'] ?? null;
    $js  = $manifest['src/app.js']['file']  ?? null;

    if ($css) wp_enqueue_style('umm-app', get_theme_file_uri("dist/{$css}"), [], null);
    if ($js)  wp_enqueue_script('umm-app', get_theme_file_uri("dist/{$js}"), [], null, ['strategy' => 'defer']);
});

Tailwind v4 scans the theme directory automatically, including .php templates, so classes in template-parts/ are picked up without configuration. For dynamic classes built in PHP ("bg-{$color}-50"), either enumerate them in a comment the scanner can see, or use @source inline("bg-violet-50 bg-emerald-50 …") in v4.1+.

What it bought me

Metric (agency theme, 60 templates)v3 (JIT)v4 (Oxide)
Full production build1.9 s0.11 s
Incremental rebuild (dev)~120 ms~4 ms
Output CSS (gzipped)18.2 KB16.7 KB
Config lines74 (JS)31 (CSS)
Files changed by migration-41 auto + 9 manual

The CSS size drop comes from v4 leaning on native features like color-mix() instead of generating opacity variants. The build-time drop is Oxide. Neither matters as much as the CSS-variables model, which finally lets Tailwind and hand-written CSS share one source of truth. If you are building components on top of it, the type-safety side of the stack is covered in TypeScript Patterns for Scalable React Apps, and for shipping the build through CI see GitHub Actions CI/CD.