Upgrading a Tailwind 3.x monorepo (several apps + shared UI packages, mixed build tools) to v4.x. Repo-agnostic — generic package names throughout.
TL;DR: the config and engine change, but the thing that breaks your UI is the new cascade-layer model. What used to win on specificity can now lose to layers. Most "one page looks broken" bugs are a v3 prebuilt-CSS dump leaking into a v4 app.
@theme replaces the shared JS preset — one source of truth, no duplicated tailwind.config.ts.Don't oversell "loads only the CSS you use" (v3's JIT already did) or responsive consistency (sm:/md: are unchanged).
| Area | v3 | v4 |
|---|---|---|
| Config | tailwind.config.{js,ts} | CSS-first: @theme, @plugin, @source |
| Entry | @tailwind base/components/utilities | @import "tailwindcss"; |
| Engine | PostCSS/JS | Oxide (Rust) + Lightning CSS |
| Content | content: [...] globs | auto-detect + @source |
| Presets | presets: [config] | none — share tokens via a CSS @theme file |
| Dark mode | darkMode: 'class' | @custom-variant dark (&:is(.dark *)); |
Border / ring default | gray-200 / 3px | currentColor / 1px |
| Important | !flex | flex! |
| Opacity | bg-opacity-50 | bg-black/50 |
npx @tailwindcss/upgrade also value-preserves renames (shadow → shadow-sm, rounded → rounded-sm, ring → ring-3, outline-none → outline-hidden).
Watch the codemod's variant reorders.
hover:[&_a]:↔[&_a]:hover:change which element the state targets — re-review each.
In v3, specificity + source order decided collisions. In v4, cascade layers decide first, and unlayered CSS beats any layered CSS.
@layer utilities {
.border { border-width: 1px } /* layered */
}
*, ::before, ::after { border-width: 0 } /* unlayered → WINS → every border vanishes */
Internalize this before you start — it explains most v4 "breakage" reports.
1. Prebuilt CSS dumps (the #1 trap). A shared package that ships dist/index.css (Preflight + all utilities) is fine in a v3 host and poison in a v4 one: its unlayered Preflight overrides your layered utilities, so borders/spacing vanish. Tell: only one route breaks (the one importing the dump), reproduces in incognito, started after migration. Fixes, best first:
@source "../packages/ui/**/*.{ts,tsx}".@layer base (non-destructive, reversible).Find them: grep sources for ! tailwindcss v3 and *, ::before, ::after {.
2. No presets → share tokens as CSS. Turn the shared JS theme into a tokens-only file (no @import "tailwindcss", no @plugin, so it imports anywhere):
/* @org/config-tailwind/theme.css */
@custom-variant dark (&:is(.dark *));
@theme { --color-primary: hsl(var(--primary)); --radius-lg: var(--radius); }
Each consumer owns its own @import "tailwindcss", @plugins, and @source, then imports the token file.
3. @apply in other files needs @reference. Non-entry files have no theme context, so @apply errors. Add a reference (it loads theme/utilities without emitting them):
@reference "../app/globals.css";
.ProseMirror { @apply rounded-sm px-4 }
4. The transform/translate split. v4 writes translate-*/scale-*/rotate-* to their own properties, not transform. A leftover transform: translate(...) now double-applies → elements drift. The transform: none band-aid then kills animations (e.g. shadcn DialogContent). Once the v3 source is gone, remove the band-aid.
5. Dev-server staleness. After config deletions / @source edits / reinstalls, a long-running server serves stale CSS that looks exactly like a code bug. Kill it, clear the bundler cache (node_modules/.vite), restart.
Also worth knowing: @import "tailwindcss" source(none) disables auto-detection (only explicit @source scans), and a token used in markup but missing from @theme silently emits nothing.
# 1. Generate CSS out-of-band and grep for the class
npx @tailwindcss/cli@4 -i src/index.css -o /tmp/out.css
grep -A2 -F '.border' /tmp/out.css # does it set border-width:1px?
# 2. Inspect what the dev server actually serves (catches stale servers)
curl -s http://localhost:3000/src/index.css | grep -c 'border-border'
// 3. Computed-style probe in a real browser (catches layer/override bugs)
const el = document.createElement('div')
el.className = 'border border-border/60 rounded-xl'
document.body.appendChild(el)
getComputedStyle(el).borderTopWidth // '0px' = bug, '1px' = ok
@source)./* v3 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* v4 */
@import "tailwindcss";
@source "../../packages/ui/**/*.{ts,tsx}";
@custom-variant dark (&:is(.dark *));
@theme {
--color-primary: hsl(var(--primary));
--color-border: hsl(var(--border)); /* register EVERY token used in markup */
--radius-lg: var(--radius);
}
@layer base {
:root { --primary: 229 83% 60%; --border: 20 6% 90%; }
.dark { --primary: 229 83% 60%; --border: 228 5% 16%; }
* { @apply border-border } /* restore v3's default border color */
body { @apply bg-background text-foreground }
}
Grounded in a real v3.4 → v4.3 migration (multiple apps + shared UI/editor/config packages; Vite + Next.js; pnpm workspaces).
5 min read