IndexBlog

Migrating Tailwind CSS v3 → v4 in a Monorepo

June 7, 2026

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.


Why migrate

  • Kill the v3/v4 split. Mixed versions collide (see below) and cause "works on page A, broken on page B" bugs. Standardizing removes the whole class.
  • Speed. The Oxide engine is ~5× faster full, ~100× incremental.
  • CSS-first config. @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).


What actually changes

Areav3v4
Configtailwind.config.{js,ts}CSS-first: @theme, @plugin, @source
Entry@tailwind base/components/utilities@import "tailwindcss";
EnginePostCSS/JSOxide (Rust) + Lightning CSS
Contentcontent: [...] globsauto-detect + @source
Presetspresets: [config]none — share tokens via a CSS @theme file
Dark modedarkMode: 'class'@custom-variant dark (&:is(.dark *));
Border / ring defaultgray-200 / 3pxcurrentColor / 1px
Important!flexflex!
Opacitybg-opacity-50bg-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.


The one concept that matters: layers beat specificity

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.


Monorepo traps

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:

  • Don't import the dump. Let the app generate the package's classes: @source "../packages/ui/**/*.{ts,tsx}".
  • If it must ship a stylesheet, wrap its reset in @layer base (non-destructive, reversible).
  • Migrate the package to v4 so it stops emitting a v3 dump.

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.


Verify — don't trust, measure

# 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

Migration order

  1. Inventory every package: Tailwind version, whether it ships a dump, who imports it.
  2. Foundation first: shared tokens → shared UI (stop the dump) → apps (replace dump imports with @source).
  3. One package per PR. Run the codemod per package; hand-review the variant reorders.
  4. Verify each build (not just dev) before moving on.

The entry file, before & after

/* 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

0
likes