Tailwind v4 + shadcn/ui Production Stack
Production-tested: WordPress Auditor (https://wordpress-auditor.webfonts.workers.dev) Last Updated: 2026-01-20 Versions: tailwindcss@4.1.18, @tailwindcss/vite@4.1.18 Status: Production Ready ✅
Quick Start (Follow This Exact Order)
# 1. Install dependencies
pnpm add tailwindcss @tailwindcss/vite
pnpm add -D @types/node tw-animate-css
pnpm dlx shadcn@latest init
# 2. Delete v3 config if exists
rm tailwind.config.ts # v4 doesn't use this file
vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: { alias: { '@': path.resolve(__dirname, './src') } }
})
components.json (CRITICAL):
{
"tailwind": {
"config": "", // ← Empty for v4
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
}
}
The Four-Step Architecture (MANDATORY)
Skipping steps will break your theme. Follow exactly:
Step 1: Define CSS Variables at Root
/* src/index.css */
@import "tailwindcss";
@import "tw-animate-css"; /* Required for shadcn/ui animations */
:root {
--background: hsl(0 0% 100%); /* ← hsl() wrapper required */
--foreground: hsl(222.2 84% 4.9%);
--primary: hsl(221.2 83.2% 53.3%);
/* ... all light mode colors */
}
.dark {
--background: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%);
--primary: hsl(217.2 91.2% 59.8%);
/* ... all dark mode colors */
}
Critical: Define at root level (NOT inside @layer base). Use hsl() wrapper.
Step 2: Map Variables to Tailwind Utilities
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
/* ... map ALL CSS variables */
}
Why: Generates utility classes (bg-background, text-primary). Without this, utilities won't exist.
Step 3: Apply Base Styles
@layer base {
body {
background-color: var(--background); /* NO hsl() wrapper here */
color: var(--foreground);
}
}
Critical: Reference variables directly. Never double-wrap: hsl(var(--background)).
Step 4: Result - Automatic Dark Mode
<div className="bg-background text-foreground">
{/* No dark: variants needed - theme switches automatically */}
</div>
Dark Mode Setup
1. Create ThemeProvider (see templates/theme-provider.tsx)
2. Wrap App:
// src/main.tsx
import { ThemeProvider } from '@/components/theme-provider'
ReactDOM.createRoot(document.getElementById('root')!).render(
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<App />
</ThemeProvider>
)
3. Add Theme Toggle:
pnpm dlx shadcn@latest add dropdown-menu
See reference/dark-mode.md for ModeToggle component.
Critical Rules
✅ Always Do:
- Wrap colors with
hsl()in:root/.dark:--bg: hsl(0 0% 100%); - Use
@theme inlineto map all CSS variables - Set
"tailwind.config": ""in components.json - Delete
tailwind.config.tsif exists - Use
@tailwindcss/viteplugin (NOT PostCSS)
❌ Never Do:
- Put
:root/.darkinside@layer base(causes cascade issues) - Use
.dark { @theme { } }pattern (v4 doesn't support nested @theme) - Double-wrap colors:
hsl(var(--background)) - Use
tailwind.config.tsfor theme (v4 ignores it) - Use
@applydirective (deprecated in v4, see error #7) - Use
dark:variants for semantic colors (auto-handled) - Use
@applywith@layer baseor@layer componentsclasses (v4 breaking change - use@utilityinstead) | Source - Wrap ANY styles in
@layer basewithout understanding CSS layer ordering (see error #8) | Source
Common Errors & Solutions
This skill prevents 8 documented errors.
1. ❌ tw-animate-css Import Error
Error: "Cannot find module 'tailwindcss-animate'"
Cause: shadcn/ui deprecated tailwindcss-animate for v4.
Solution:
# ✅ DO
pnpm add -D tw-animate-css
# Add to src/index.css:
@import "tailwindcss";
@import "tw-animate-css";
# ❌ DON'T
npm install tailwindcss-animate # v3 only
2. ❌ Colors Not Working
Error: bg-primary doesn't apply styles
Cause: Missing @theme inline mapping
Solution:
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
/* ... map ALL CSS variables */
}
3. ❌ Dark Mode Not Switching
Error: Theme stays light/dark
Cause: Missing ThemeProvider
Solution:
- Create ThemeProvider (see
templates/theme-provider.tsx) - Wrap app in
main.tsx - Verify
.darkclass toggles on<html>element
4. ❌ Duplicate @layer base
Error: "Duplicate @layer base" in console
Cause: shadcn init adds @layer base - don't add another
Solution:
/* ✅ Correct - single @layer base */
@import "tailwindcss";
:root { --background: hsl(0 0% 100%); }
@theme inline { --color-background: var(--background); }
@layer base { body { background-color: var(--background); } }
5. ❌ Build Fails with tailwind.config.ts
Error: "Unexpected config file"
Cause: v4 doesn't use tailwind.config.ts (v3 legacy)
Solution:
rm tailwind.config.ts
v4 configuration happens in src/index.css using @theme directive.
6. ❌ @theme inline Breaks Dark Mode in Multi-Theme Setups
Error: Dark mode doesn't switch when using @theme inline with custom variants (e.g., data-mode="dark") Source: GitHub Discussion #18560
Cause: @theme inline bakes variable VALUES into utilities at build time. When dark mode changes the underlying CSS variables, utilities don't update because they reference hardcoded values, not variables.
Why It Happens:
@theme inlineinlines VALUES at build time:bg-primary→background-color: oklch(...)- Dark mode overrides change the CSS variables, but utilities already have baked-in values
- The CSS specificity chain breaks
Solution: Use @theme (without inline) for multi-theme scenarios:
/* ✅ CORRECT - Use @theme without inline */
@custom-variant dark (&:where([data-mode=dark], [data-mode=dark] *));
@theme {
--color-text-primary: var(--color-slate-900);
--color-bg-primary: var(--color-white);
}
@layer theme {
[data-mode="dark"] {
--color-text-primary: var(--color-white);
--color-bg-primary: var(--color-slate-900);
}
}
When to use inline:
- Single theme + dark mode toggle (like shadcn/ui default) ✅
- Referencing other CSS variables that don't change ✅
When NOT to use inline:
- Multi-theme systems (data-theme="blue" | "green" | etc.) ❌
- Dynamic theme switching beyond light/dark ❌
Maintainer Guidance (Adam Wathan):
"It's more idiomatic in v4 for the actual generated CSS to reference your theme variables. I would personally only use inline when things don't work without it."
7. ❌ @apply with @layer base/components (v4 Breaking Change)
Error: Cannot apply unknown utility class: custom-button Source: GitHub Discussion #17082
Cause: In v3, classes defined in @layer base and @layer components could be used with @apply. In v4, this is a breaking architectural change.
Why It Happens: v4 doesn't "hijack" the native CSS @layer at-rule anymore. Only classes defined with @utility are available to @apply.
Migration:
/* ❌ v3 pattern (worked) */
@layer components {
.custom-button {
@app