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@sourcefor 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 (shadow → shadow-sm, shadow-sm → shadow-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
| v3 | v4 |
|---|---|
bg-opacity-50, text-opacity-* | Removed; use bg-black/50 slash syntax |
flex-shrink-0 / flex-grow | shrink-0 / grow |
overflow-ellipsis | text-ellipsis |
decoration-slice | box-decoration-slice |
outline-none | outline-hidden (outline-none now means outline-style: none) |
!important prefix: !mb-0 | Suffix: 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 build | 1.9 s | 0.11 s |
| Incremental rebuild (dev) | ~120 ms | ~4 ms |
| Output CSS (gzipped) | 18.2 KB | 16.7 KB |
| Config lines | 74 (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.