shadcn ui Errors: Tailwind v3 to v4 Migration
Tailwind CSS v4 (Jan 2025) rewrote the configuration model, the color format, the animation plugin, the dark mode trigger, the ring utility default, and the PostCSS integration. shadcn ui projects bootstrapped before v4 cannot be upgraded by npm install alone: the CSS file, the config file, the PostCSS file, and the package.json plugin list must all change in lockstep, or the project enters a half-migrated state where some classes resolve and others fail silently. This is the single largest hallucination risk in AI-generated shadcn code, because training data spans both generations and the v3 patterns look syntactically valid when pasted into a v4 project.
This skill gives a deterministic migration runbook and the detection rules to identify which generation a project is on before changing any code.
Quick Reference
Major shifts table (v3 -> v4)
| Concept | v3 (pre-2025) | v4 (evergreen-2026) |
|---|---|---|
| Config location | tailwind.config.js (JS) |
@theme inline block in CSS |
| CSS import | @tailwind base; @tailwind components; @tailwind utilities; |
@import "tailwindcss"; |
| Color format | --background: 0 0% 100% (HSL space-separated) |
--background: oklch(1 0 0) |
| Color reference | bg-[hsl(var(--background))] or tailwind.config.js color extension |
bg-background (direct, via @theme inline) |
| Animation plugin | tailwindcss-animate (npm package, in plugins) |
tw-animate-css (CSS @import, no plugin entry) |
| Dark mode trigger | darkMode: ['class'] in tailwind.config.js |
@custom-variant dark (&:is(.dark *)); in CSS |
| Ring default width | ring = 3px, ring-2 = 2px |
ring = 1px, ring-3 = 3px |
| PostCSS plugin | tailwindcss: {} |
'@tailwindcss/postcss': {} |
| Vite plugin | none required (via PostCSS) | @tailwindcss/vite preferred |
@tailwind directives |
required | REMOVED, do not use |
forwardRef in components |
required pattern | REMOVED, plain function declarations |
Detection rules (read these BEFORE touching code)
| Signal | Conclusion |
|---|---|
tailwind.config.js or tailwind.config.ts exists at project root |
Project was bootstrapped as v3 |
globals.css contains @tailwind base; |
Project is v3 |
globals.css contains @import "tailwindcss"; |
Project is v4 |
globals.css contains @theme inline |
Project is v4 |
globals.css contains --background: 0 0% 100% (no oklch, no hsl()) |
Token block is v3 |
globals.css contains --background: oklch(...) |
Token block is v4 |
package.json lists tailwindcss-animate |
Plugin is v3-era |
package.json lists tw-animate-css |
Plugin is v4-era |
postcss.config.js lists tailwindcss plugin |
PostCSS is v3-style |
postcss.config.js lists '@tailwindcss/postcss' plugin |
PostCSS is v4-style |
components/ui/*.tsx uses React.forwardRef |
Components are v3-era |
components/ui/*.tsx uses plain function Component({...}) with data-slot |
Components are v4-era |
ALWAYS run the detection scan first. NEVER edit one signal in isolation: a project where globals.css is v4 but tailwind.config.js still exists is a half-migrated project and produces unpredictable builds.
Migration runbook (deterministic order)
ALWAYS execute these steps in this exact order. Each step depends on the previous.
- Branch and snapshot :
git checkout -b tailwind-v4-migration. NEVER migrate onmain. - Verify Node version :
node --versionmust be>= 20. The v4 codemod requires Node 20+. - Run the official codemod :
npx @tailwindcss/upgrade. This rewrites dependencies, the config file, and template files automatically. - Update dependencies to latest shadcn-compatible versions :
pnpm up "@radix-ui/*" lucide-react recharts tailwind-merge clsx --latest. - Replace the animate plugin : remove
tailwindcss-animatefrompackage.jsondependencies. Addtw-animate-css. Replace@plugin 'tailwindcss-animate';(if present) with@import "tw-animate-css";at the top ofglobals.css. - Verify token format : every CSS variable in
:rootand.darkmust useoklch(...),hsl(...), or a named color, NEVER raw space-separated HSL components. - Verify @theme inline block :
globals.cssmust contain@theme inline { --color-background: var(--background); ... }so semantic class names likebg-backgroundresolve. - Verify dark mode wiring :
globals.cssmust contain@custom-variant dark (&:is(.dark *));. ThedarkModekey intailwind.config.jsis no longer read. Iftailwind.config.jsstill exists, delete it OR keep it ONLY if the project intentionally retains v3-compat mode (rare). - Verify ring utility usage : grep for
ring-2in the codebase. v3 components usedring-2for the focus ring. In v4 the visual equivalent isring-3(becauseringitself dropped from 3px to 1px). Audit shadcn components that were copied pre-v4 (Button, Input, Select, Tabs, Switch). - Re-run shadcn-add for any pre-v4 components :
pnpm dlx shadcn@latest add button input select --overwriteregenerates the components in v4 form (withdata-slot, withoutforwardRef, with v4-correct ring width). NEVER hand-edit the upgrade for a component when re-running the CLI will do it deterministically. - Update PostCSS :
postcss.config.jsmust list'@tailwindcss/postcss': {}instead oftailwindcss: {}. Removepostcss-importandautoprefixerentries (v4 handles imports and prefixing internally). - Update Vite (if applicable) : install
@tailwindcss/viteand addtailwindcss()to thevite.config.tspluginsarray. This is now the preferred integration for Vite projects. - Build and verify : run
pnpm build. The build must complete with zero "unknown at-rule" warnings. Open the app, toggle dark mode, focus a Button, and confirm the ring appears at the expected thickness. - Visual regression sweep : open every page. v3 -> v4 frequently regresses cursor on Button (v4 ships
cursor: defaulton<button>; shadcn does NOT addcursor-pointer), ring thickness on focused inputs, accordion expand/collapse animation timing, and chart fill colors (whenchartConfigstill wrapshsl(var(--chart-1))instead ofvar(--chart-1)).
NEVER stop at step 3. The codemod handles most of the rewrite but does not always migrate tailwindcss-animate to tw-animate-css, does not always rewrite ring-2 usages, and does not re-run the shadcn CLI to refresh component files. Steps 5 through 13 are manual.
Per-file changes
globals.css
The file flips from @tailwind directives + @layer base token block to @import "tailwindcss" + raw token block + @theme inline mapping + @custom-variant dark + @import "tw-animate-css". See examples.md for the full before/after.
tailwind.config.js
In v4 the JS config is OPTIONAL and not auto-detected. The preferred path for shadcn evergreen-2026 is: DELETE tailwind.config.js and move all theming into globals.css. If the project has app-specific extensions (custom fonts, custom keyframes that are not animation-related) the v4 escape hatch is @config "../../tailwind.config.js"; at the top of globals.css, but for stock shadcn projects this is not needed.
postcss.config.js
Three changes: rename the plugin key from tailwindcss to '@tailwindcss/postcss', remove postcss-import (v4 handles it), and remove autoprefixer (v4 handles vendor prefixing). The whole file becomes a single-plugin export. See examples.md.
package.json
Remove tailwindcss v3 entry. Add tailwindcss v4 + @tailwindcss/postcss (or @tailwindcss/vite for Vite projects). Remove tailwindcss-animate. Add tw-animate-css. Remove autoprefixer and postcss-import if they were standalone. The codemod from step 3 typically handles dependency bumps, but the animate-plugin swap is manual.
Verification checklist
After completing the runbook, ALL of these must be true:
-
node --versionreports >= 20 -
tailwind.config.jsis either deleted OR referenced explicitly via@configin CSS -
globals.cssstarts with@import "tailwindcss"; -
globals.csscontains exactly one@theme inlineblock -
globals.csscontains@custom-variant dark (&:is(.dark *)); -
globals.cssimportstw-animate-css(NOT referencestailwindcss-animate) - All CSS variables use
oklch(...),hsl(...), or named colors; NO raw0 0% 100% -
package.jsonhas NOtailwindcss-animateentry -
package.jsonhastw-animate-cssANDtailwindcssv4 -
postcss.config.jslists'@tailwindcss/postcss', nottailwindcss - Vite projects use
@tailwindcss/viteplugin - No file mixes
bg-[hsl(var(--background))]withbg-background(the former is v3 syntax) - No file mixes
ring-2with components that were re-pulled in v4 form - Dark mode toggle adds
class="dark"on<html>AND the page restyles - Build emits zero "unknown at-rule" warnings
If ANY checkbox fails, the project is half-migrated. Re-enter the runbook at the appropriate step.
Decision Trees
"My colors broke after upgrade"
Did colors break?
├── Yes : open globals.css
│ ├── Tokens use `0 0% 100%` (no oklch, no hsl wrapper) AND file imports tailwindcss v4
│ │ └── This is the canonical v3-token-in-v4-file bug
│ │ Fix : wrap each token with `oklch(...)` or `hsl(...)`, e.g. `--background: oklch(1 0 0);`
│ ├── Tokens use `oklch(...)` AND tailwind.config.js still extends colors with `hsl(var(--background))`
│ │ └── Two color sources fighting; the JS config still expects HSL
│ │ Fix : delete tailwind.config.js color extension, add `@theme inline { --color-background: var(--background); }` in CSS
│ └── Tokens look correct, but className uses `bg-[hsl(var(--background))]`
│ └── This is v3 arbitrary-class syntax
│ Fix : replace with `bg-background`
└── No : skip
"My animations stopped working"
Is package.json still listing tailwindcss-animate?
├── Yes : v4 removed support for this plugin
│ Fix :
│ 1. `pnpm remove tailwindcss-animate`
│ 2. `pnpm add tw-animate-css`
│ 3. In globals.css, add `@import "tw-animate-css";` near the top
│ 4. Remove any `@plugin 'tailwindcss-animate';` line from CSS
└── No : check the build log for "unknown at-rule" warnings
└── @plugin syntax may still reference the old plugin name
"My dark toggle stopped working"
Open globals.css
├── No `@custom-variant dark` line found
│ └── v3 relied on `darkMode: ['class']` in JS config; v4 needs CSS variant
│ Fix : add `@custom-variant dark (&:is(.dark *));` after the `@import "tailwindcss";` line
├── `@custom-variant dark` present AND tailwind.config.js still has `darkMode: ['class']`
│ └── Both wired; usually harmless but indicates incomplete migration
│ Fix : delete tailwind.config.js OR remove the `darkMode` key
└── `@custom-variant dark` present AND .dark token block missing in globals.css
└── Variant exists but has nothing to switch to
Fix : add a `.dark { --background: oklch(...); ... }` block defining every token
Companion Skills
- shadcn-core-theming (B2) : Full theming reference covering tokens, semantic color naming, sidebar tokens, chart tokens. Use AFTER migration to understand the v4 token shape.
- shadcn-errors-styling-conflicts (B11) : When
classNameoverrides do not visually win, especially during a half-migrated v3/v4 project where class semantics shift mid-file. - shadcn-impl-init : Bootstrapping a fresh project goes through
shadcn initwhich generates v4 by default in evergreen-2026; use this skill if migrating an existing project rather than starting fresh.
Reference Links
- shadcn ui Tailwind v4 guide : https://ui.shadcn.com/docs/tailwind-v4
- Tailwind CSS v3 to v4 upgrade guide : https://tailwindcss.com/docs/upgrade-guide
- Tailwind v4 codemod : https://www.npmjs.com/package/@tailwindcss/upgrade
- tw-animate-css : https://www.npmjs.com/package/tw-animate-css
- shadcn ui theming : https://ui.shadcn.com/docs/theming
- @tailwindcss/postcss : https://www.npmjs.com/package/@tailwindcss/postcss
- @tailwindcss/vite : https://www.npmjs.com/package/@tailwindcss/vite
See references/methods.md for the v3-vs-v4 syntax reference table per concept.
See references/examples.md for full before/after files (globals.css, tailwind.config.js, postcss.config.js, package.json).
See references/anti-patterns.md for the six most common half-migration bugs and their root causes.