Set up Tailwind v4 + shadcn/ui themed UI with dark mode. Install deps, configure CSS variables via @theme inline, wire dark mode toggle, verify. Use whenever…
Tailwind Theme Builder
Set up a fully themed Tailwind v4 + shadcn/ui project with dark mode. Produces configured CSS, theme provider, and working component library.
Architecture: The Four-Step Pattern
Tailwind v4 requires a specific architecture for CSS variable-based theming. This pattern is mandatory -- skipping or modifying steps breaks the theme.
How It Works
CSS Variable Definition --> @theme inline Mapping --> Tailwind Utility Class
--background --> --color-background --> bg-background
(with hsl() wrapper) (references variable) (generated class)
Dark mode switching:
ThemeProvider toggles .dark class on <html>
--> CSS variables update automatically (.dark overrides :root)
--> Tailwind utilities reference updated variables
--> UI updates without re-render
Best Practices
Semantic names: Use --primary not --blue-500
Foreground pairing: Every background colour needs a foreground (--primary + --primary-foreground)
WCAG contrast: Normal text 4.5:1, large text 3:1, UI components 3:1
Chart colours: Use separate variables with @theme inline mapping, reference via var(--chart-1) in style props
Workflow
Step 1: Install Dependencies
pnpm add tailwindcss @tailwindcss/vite
pnpm add -D @types/node tw-animate-css
pnpm dlx shadcn@latest init
# Delete v3 config if it exists
rm -f tailwind.config.ts
Step 2: Configure Vite
Copy assets/vite.config.ts or add the Tailwind plugin:
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') } }
})
Step 3: Four-Step CSS Architecture (Mandatory)
This exact order is required. Skipping steps breaks the theme.
src/index.css:
@import "tailwindcss";
@import "tw-animate-css";
/* 1. Define CSS variables at root (NOT inside @layer base) */
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(222.2 84% 4.9%);
--primary: hsl(221.2 83.2% 53.3%);
--primary-foreground: hsl(210 40% 98%);
/* ... all semantic tokens */
}
.dark {
--background: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%);
--primary: hsl(217.2 91.2% 59.8%);
--primary-foreground: hsl(222.2 47.4% 11.2%);
}
/* 2. Map variables to Tailwind utilities */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
}
/* 3. Apply base styles (NO hsl() wrapper here) */
@layer base {
body {
background-color: var(--background);
color: var(--foreground);
}
}
Result: bg-background, text-primary etc. work automatically. Dark mode switches via .dark class -- no dark: variants needed for semantic colours.
Step 4: Set Up Dark Mode
Copy assets/theme-provider.tsx to your components directory, then wrap your app:
import { ThemeProvider } from '@/components/theme-provider'
ReactDOM.createRoot(document.getElementById('root')!).render(
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<App />
</ThemeProvider>
)
Add a theme toggle -- install the dropdown menu then use the ModeToggle component below:
pnpm dlx shadcn@latest add dropdown-menu
// src/components/mode-toggle.tsx
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useTheme } from "@/components/theme-provider"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
Step 5: Configure components.json
{
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
}
}
"config": "" is critical -- v4 doesn't use tailwind.config.ts.
Critical Rules
Always:
Wrap colours with hsl() in :root/.dark
Use @theme inline to map all CSS variables
Use @tailwindcss/vite plugin (NOT PostCSS)
Delete tailwind.config.ts if it exists
Never:
Put :root/.dark inside @layer base
Use .dark { @theme { } } (v4 doesn't support nested @theme)
Double-wrap: hsl(var(--background))
Use @apply with @layer base classes (use @utility instead)
All 18 Gotchas
Quick Diagnosis
#
Symptom
Cause
Fix
1
Variables ignored / theme broken
:root inside @layer base
Move :root and .dark to root level
2
Dark mode colours not switching
.dark { @theme { } }
Use CSS variables + single @theme inline
3
Colours all black/white
Double hsl() wrapping
Use var(--background) not hsl(var(...))
4
bg-primary not generated
Colours in tailwind.config.ts
Delete config, use @theme inline
5
bg-background class missing
No @theme inline block
Add @theme inline mapping variables
6
shadcn components break
components.json has config path
Set "config": "" (empty string)
7
Tailwind not processing
Using PostCSS plugin
Switch to @tailwindcss/vite plugin
8
@/ imports fail
Missing path aliases
Add paths to tsconfig.app.json
9
Redundant dark: variants
Using dark:bg-primary-dark
Just use bg-primary -- variables handle it
10
Hardcoded colours everywhere
Using bg-blue-600 dark:bg-blue-400
Use semantic tokens: bg-primary
11
Class merging bugs
String concatenation for classes
Use cn() from @/lib/utils
12
Radix Select crashes
Empty string value value=""
Use value="placeholder"
13
Wrong Tailwind version
Installed tailwindcss@^3
Install tailwindcss@^4.1.0 + @tailwindcss/vite
14
Missing peer deps
Only installed tailwindcss
Also install clsx, tailwind-merge, @types/node
15
Broken in dark mode
Only tested light mode
Test light, dark, system, and toggle transitions
16
Fails WCAG contrast
Looks fine visually
Check ratios: 4.5:1 normal text, 3:1 large/UI
17
Build fails on animation import
Using tailwindcss-animate (deprecated)
Use tw-animate-css or native CSS animations
18
CSS priority issues
Duplicate @layer base after shadcn init
Merge into single @layer base block
Gotcha Details with Code Examples
#1 -- :root inside @layer base
Tailwind v4 strips CSS outside @theme/@layer, but :root must be at root level to persist. This is the most common setup failure.
WRONG:
@layer base {
:root { --background: hsl(0 0% 100%); }
}
CORRECT:
:root { --background: hsl(0 0% 100%); }
@layer base {
body { background-color: var(--background); }
}
#2 -- Nested @theme
Tailwind v4 does not support @theme inside selectors. Use CSS variables in :root/.dark with a single @theme inline block.
WRONG:
@theme { --color-primary: hsl(0 0% 0%); }
.dark { @theme { --color-primary: hsl(0 0% 100%); } }
CORRECT:
:root { --primary: hsl(0 0% 0%); }
.dark { --primary: hsl(0 0% 100%); }
@theme inline { --color-primary: var(--primary); }
#3 -- Double hsl() wrapping
Variables already contain hsl(). Double-wrapping creates hsl(hsl(...)).
WRONG: background-color: hsl(var(--background));
CORRECT: background-color: var(--background);
#4 -- Colours in tailwind.config.ts
Tailwind v4 completely ignores theme.extend.colors in config files. Delete the file or leave it empty. Set "config": "" in components.json.
#5 -- Missing @theme inline
Without @theme inline, Tailwind has no knowledge of your CSS variables. Utility classes like bg-background simply won't be generated.
WRONG:
:root { --background: hsl(0 0% 100%); }
/* No @theme inline block -- bg-background won't exist */
CORRECT:
:root { --background: hsl(0 0% 100%); }
@theme inline { --color-background: var(--background); }
#7 -- PostCSS vs Vite plugin
WRONG:
export default defineConfig({
css: { postcss: './postcss.config.js' } // Old v3 way
})
CORRECT:
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()] // v4 way
})
#8 -- Path aliases
Add to tsconfig.app.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
}
}
#11 -- cn() utility for class merging
WRONG: className={`base ${isActive && 'active'}`}
CORRECT: className={cn("base", isActive && "active")}
cn() from @/lib/utils properly merges and deduplicates Tailwind classes.
#12 -- Radix Select empty value
Radix UI Select does not allow empty string values. Use value="placeholder" instead of value="".
#14 -- Required dependencies
{
"dependencies": {
"tailwindcss": "^4.1.0",
"@tailwindcss/vite": "^4.1.0",
"clsx": "^2.1.1",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@types/node": "^24.0.0"
}
}
#17 -- tw-animate-css
tailwindcss-animate is deprecated in Tailwind v4. shadcn/ui docs may still reference it. Causes build failures and import errors. Use tw-animate-css or @tailwindcss/motion instead.
#18 -- Duplicate @layer base after shadcn init
shadcn init adds its own @layer base block. Check src/index.css immediately after running init and merge any duplicate blocks into one.
WRONG:
@layer base { body { background-color: var(--background); } }
@layer base { * { border-color: hsl(var(--border)); } } /* duplicate from shadcn */
CORRECT:
@layer base {
* { border-color: var(--border); }
body { background-color: var(--background); color: var(--foreground); }
}
Prevention Checklist
No tailwind.config.ts file (or it's empty)
components.json has "config": ""
All colors have hsl() wrapper in :root
@theme inline maps all variables
@layer base doesn't wrap :root
Theme provider wraps app
Tested in light, dark, and system modes
All text has sufficient contrast
Dark Mode Testing Checklist
Light mode displays correctly
Dark mode displays correctly
System mode respects OS setting
Theme persists after page refresh
Toggle component shows current state
All text has proper contrast
No flash of wrong theme on load
Works in incognito mode (graceful fallback)
Asset Files
Copy from assets/ directory:
index.css -- Complete CSS with all colour variables
components.json -- shadcn/ui v4 config
vite.config.ts -- Vite + Tailwind plugin
theme-provider.tsx -- Dark mode provider
utils.ts -- cn() utility
Reference Files
references/migration-guide.md -- v3 to v4 migration
Official Documentation
shadcn/ui Tailwind v4 Guide: https://ui.shadcn.com/docs/tailwind-v4
shadcn/ui Dark Mode (Vite): https://ui.shadcn.com/docs/dark-mode/vite
shadcn/ui Theming: https://ui.shadcn.com/docs/theming
Tailwind v4 Docs: https://tailwindcss.com/docs
Tailwind Dark Mode: https://tailwindcss.com/docs/dark-modedon't have the plugin yet? install it then click "run inline in claude" again.
restructured original skill into implexa's 6 required components, added explicit decision points for common failures, documented edge cases (wcag contrast, localStorage, system mode fallback), clarified css variable architecture with mandatory step order, added outcome signals for dark mode persistence and wcag compliance, preserved all original author content and gotchas.
build a fully themed tailwind v4 + shadcn/ui project with dark mode support. this skill sets up css variable-based theming via @theme inline mapping, installs the tailwind vite plugin, configures a theme provider for dark mode toggling, and validates the setup works across light/dark/system modes. use this when starting a new react + tailwind v4 project that needs semantic color tokens, dark mode, and shadcn/ui components working out of the box.
step 1: install dependencies
run these commands in order:
pnpm add tailwindcss @tailwindcss/vite
pnpm add -D @types/node tw-animate-css
pnpm dlx shadcn@latest init
after shadcn init completes, delete any v3 config:
rm -f tailwind.config.ts
input: project root with package.json and src/ directory. output: tailwindcss@^4.1.0, @tailwindcss/vite installed; shadcn/ui initialized; no tailwind.config.ts file.
step 2: configure vite
update vite.config.ts to include the tailwind plugin and path alias:
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') } }
})
also update tsconfig.app.json to enable path aliases:
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
}
}
input: vite.config.ts, tsconfig.app.json. output: vite configured with @tailwindcss/vite plugin; @ alias resolves to src/.
step 3: configure css architecture (mandatory order)
create or replace src/index.css with this exact structure. order matters: variables first, then @theme inline, then @layer base.
@import "tailwindcss";
@import "tw-animate-css";
/* 1. define css variables at root level (NOT inside @layer base) */
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(222.2 84% 4.9%);
--primary: hsl(221.2 83.2% 53.3%);
--primary-foreground: hsl(210 40% 98%);
--secondary: hsl(210 40% 96%);
--secondary-foreground: hsl(222.2 47.4% 11.2%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(210 40% 98%);
--muted: hsl(210 40% 96%);
--muted-foreground: hsl(215.4 16.3% 46.9%);
--accent: hsl(217.2 91.2% 59.8%);
--accent-foreground: hsl(222.2 47.4% 11.2%);
--border: hsl(214.3 31.8% 91.4%);
--input: hsl(214.3 31.8% 91.4%);
--ring: hsl(221.2 83.2% 53.3%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
}
.dark {
--background: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%);
--primary: hsl(217.2 91.2% 59.8%);
--primary-foreground: hsl(222.2 47.4% 11.2%);
--secondary: hsl(217.2 32.6% 17.5%);
--secondary-foreground: hsl(210 40% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(210 40% 98%);
--muted: hsl(217.2 32.6% 17.5%);
--muted-foreground: hsl(215 20.2% 65.1%);
--accent: hsl(217.2 91.2% 59.8%);
--accent-foreground: hsl(222.2 47.4% 11.2%);
--border: hsl(217.2 32.6% 17.5%);
--input: hsl(217.2 32.6% 17.5%);
--ring: hsl(212.7 26.8% 83.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
}
/* 2. map variables to tailwind utilities via @theme inline */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
}
/* 3. apply base styles (use var() not hsl() here) */
@layer base {
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
}
}
input: src/index.css path, semantic color palette (hsl tuples for light and dark). output: src/index.css with :root/.dark at root level, @theme inline block present, @layer base applied to body.
step 4: set up dark mode provider
create src/components/theme-provider.tsx:
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "light" | "dark" | "system"
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
}: {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}) {
const [theme, setThemeState] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = document.documentElement
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
const activeTheme = theme === "system" ? systemTheme : theme
root.classList.remove("light", "dark")
root.classList.add(activeTheme)
localStorage.setItem(storageKey, theme)
}, [theme, storageKey])
const setTheme = (newTheme: Theme) => setThemeState(newTheme)
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within ThemeProvider")
}
return context
}
wrap your app with ThemeProvider in main.tsx or your entry point:
import { ThemeProvider } from '@/components/theme-provider'
ReactDOM.createRoot(document.getElementById('root')!).render(
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<App />
</ThemeProvider>
)
input: src/components/ directory, main.tsx or entry point. output: theme-provider.tsx created; app wrapped with ThemeProvider; localStorage persists theme choice.
step 5: add theme toggle component
install shadcn dropdown menu:
pnpm dlx shadcn@latest add dropdown-menu
create src/components/mode-toggle.tsx:
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useTheme } from "@/components/theme-provider"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
input: shadcn dropdown-menu component, lucide-react icons. output: mode-toggle.tsx created and ready to drop into your navbar or header.
step 6: configure components.json
update or create components.json in your project root:
{
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
}
}
critical: set "config": "" (empty string). tailwind v4 does not use tailwind.config.ts.
input: components.json file. output: components.json with empty config path; shadcn add commands will reference src/index.css.
step 7: verify setup
run your dev server:
pnpm dev
test these checks in your browser:
input: running dev server, browser. output: visual confirmation of light/dark mode switching; console clear of errors; theme persists after refresh.
if you have an existing tailwind.config.ts file (from v3), delete it immediately before step 2. tailwind v4 ignores config files and uses @theme inline instead. keeping it causes confusion and potential conflicts.
if theme colors don't switch on dark mode toggle, verify:
if @/ imports fail, check tsconfig.app.json has "paths": { "@/": ["./src/"] }. vite.config.ts alone is not enough.
if shadcn add fails or components don't work, verify components.json has "config": "" (empty string, not a path). shadcn v4 uses this to skip config file lookup.
if tailwind utilities like bg-background don't generate, ensure @theme inline block exists in src/index.css with all color mappings. missing @theme inline is the most common cause.
if you need chart colors (separate from semantic tokens), reference them via style prop: style={{ color: 'var(--chart-1)' }}. do not try to create chart-1 utilities in @theme inline; they are for inline reference only.
if a component crashes or renders incorrectly in dark mode but works in light, test wcag contrast ratios. dark colors may be too close to background color. use contrast checker tool and adjust .dark hsl() values.
success means:
data format: css variables are hsl(h s% l%) tuples; dark mode state stored in localStorage under key specified in ThemeProvider (default: "vite-ui-theme"); theme value is one of "light", "dark", or "system".
file locations:
you know this worked when: