
Tailwind Theme Builder
- 2.9k installs
- 946 repo stars
- Updated July 2, 2026
- jezweb/claude-skills
This agent skill configures Tailwind v4 plus shadcn/ui with @theme inline variables, semantic colours, and a working dark mode toggle.
About
This Tailwind v4 theme skill configures shadcn/ui with CSS variable theming and dark mode for React Vite projects. It follows a mandatory four-step pattern: define semantic variables in :root and .dark with hsl wrappers, map them through @theme inline to generated utilities such as bg-background, apply base styles without double hsl wrapping, and wire a ThemeProvider that toggles the .dark class on html. Workflow steps install tailwindcss and @tailwindcss/vite, remove legacy tailwind.config.ts, configure the Vite plugin, copy theme-provider and mode-toggle components, and set components.json with an empty tailwind config path for v4. Critical rules forbid nesting :root inside @layer base, nested @theme blocks, double hsl(var(...)) usage, and @apply on base layer classes. Documentation includes eighteen gotchas with symptom-cause-fix tables covering tw-animate-css imports, WCAG contrast pairing for foreground tokens, and chart color variables. Developers reach for it when setting up Tailwind v4 theming, fixing colours after upgrade, or troubleshooting shadcn dark mode and @theme inline conflicts.
- Implements mandatory four-step CSS variable to @theme inline to utility class architecture.
- Uses @tailwindcss/vite plugin and deletes tailwind.config.ts for Tailwind v4 projects.
- Provides ThemeProvider and ModeToggle patterns for class-based dark mode switching.
- Documents eighteen gotchas including double hsl wrapping and @layer base placement errors.
- Configures components.json with empty tailwind config path and cssVariables enabled.
Tailwind Theme Builder by the numbers
- 2,871 all-time installs (skills.sh)
- +43 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #183 of 2,244 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
tailwind-theme-builder capabilities & compatibility
- Capabilities
- css variable token setup · @theme inline mapping · dark mode provider wiring · vite tailwind plugin config · v3 to v4 migration troubleshooting
- Use cases
- frontend · ui design
npx skills add https://github.com/jezweb/claude-skills --skill tailwind-theme-builderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.9k |
|---|---|
| repo stars | ★ 946 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 2, 2026 |
| Repository | jezweb/claude-skills ↗ |
How do I set up Tailwind v4 theming with shadcn/ui colours and dark mode without breaking @theme inline or @apply after upgrade?
Set up Tailwind v4 with shadcn/ui theming, @theme inline CSS variables, dark mode toggle, and v3 migration troubleshooting.
Who is it for?
Developers migrating to Tailwind v4 or wiring shadcn/ui CSS variable theming with class-based dark mode in Vite React apps.
Skip if: Skip when the stack is Tailwind v3 with tailwind.config.ts theming or a non-React framework without the documented pattern.
When should I use this skill?
User mentions Tailwind v4 setup, shadcn colours, dark mode toggle, tw-animate-css errors, or v3 to v4 migration issues.
What you get
Configured index.css, Vite plugin, ThemeProvider, mode toggle, and components.json with semantic utilities like bg-background working in light and dark modes.
- Configured `src/index.css` theme file
- Light and dark CSS variable token set
- shadcn-compatible `components.json` settings
By the numbers
- Follows the official shadcn/ui Tailwind v4 documentation pattern with 3-step CSS variable setup
Files
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-renderBest Practices
- Semantic names: Use
--primarynot--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 inlinemapping, reference viavar(--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.tsStep 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 inlineto map all CSS variables - Use
@tailwindcss/viteplugin (NOT PostCSS) - Delete
tailwind.config.tsif it exists
Never:
- Put
:root/.darkinside@layer base - Use
.dark { @theme { } }(v4 doesn't support nested @theme) - Double-wrap:
hsl(var(--background)) - Use
@applywith@layer baseclasses (use@utilityinstead)
---
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.tsfile (or it's empty) - [ ]
components.jsonhas"config": "" - [ ] All colors have
hsl()wrapper in:root - [ ]
@theme inlinemaps all variables - [ ]
@layer basedoesn'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 variablescomponents.json-- shadcn/ui v4 configvite.config.ts-- Vite + Tailwind plugintheme-provider.tsx-- Dark mode providerutils.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-mode
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
@import "tailwindcss";
/*
Tailwind v4 + shadcn/ui Dark Mode Pattern
Based on: https://ui.shadcn.com/docs/tailwind-v4
Key Pattern:
1. Define CSS variables at root level (NOT in @layer base)
2. Use .dark for dark mode overrides (NOT in @theme)
3. Use @theme inline to map variables to Tailwind utilities
4. All color values must use hsl() wrapper
*/
/* Light mode colors - Define at root level */
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(222.2 84% 4.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(222.2 84% 4.9%);
--popover: hsl(0 0% 100%);
--popover-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.1%);
--secondary-foreground: hsl(222.2 47.4% 11.2%);
--muted: hsl(210 40% 96.1%);
--muted-foreground: hsl(215.4 16.3% 46.9%);
--accent: hsl(210 40% 96.1%);
--accent-foreground: hsl(222.2 47.4% 11.2%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(210 40% 98%);
--success: hsl(142.1 76.2% 36.3%);
--success-foreground: hsl(210 40% 98%);
--warning: hsl(38 92% 50%);
--warning-foreground: hsl(222.2 47.4% 11.2%);
--info: hsl(221.2 83.2% 53.3%);
--info-foreground: hsl(210 40% 98%);
--border: hsl(214.3 31.8% 91.4%);
--input: hsl(214.3 31.8% 91.4%);
--ring: hsl(221.2 83.2% 53.3%);
--radius: 0.5rem;
/* Chart colors */
--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 mode colors - Plain CSS overrides (NOT in @theme) */
.dark {
--background: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%);
--card: hsl(222.2 84% 4.9%);
--card-foreground: hsl(210 40% 98%);
--popover: hsl(222.2 84% 4.9%);
--popover-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%);
--muted: hsl(217.2 32.6% 17.5%);
--muted-foreground: hsl(215 20.2% 65.1%);
--accent: hsl(217.2 32.6% 17.5%);
--accent-foreground: hsl(210 40% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(210 40% 98%);
--success: hsl(142.1 70.6% 45.3%);
--success-foreground: hsl(222.2 47.4% 11.2%);
--warning: hsl(38 92% 55%);
--warning-foreground: hsl(222.2 47.4% 11.2%);
--info: hsl(217.2 91.2% 59.8%);
--info-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(224.3 76.3% 48%);
/* Chart colors for dark mode */
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
}
/* Map CSS variables to Tailwind theme with @theme inline */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-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);
/* Border radius tokens */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
}
/* Base styles in @layer base */
@layer base {
* {
border-color: var(--border);
}
body {
margin: 0;
background-color: var(--background);
color: var(--foreground);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
type Theme = 'dark' | 'light' | 'system'
type ThemeProviderProps = {
children: ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'vite-ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(() => {
// Try localStorage first, fall back to sessionStorage, then default
try {
return (localStorage.getItem(storageKey) as Theme) ||
(sessionStorage.getItem(storageKey) as Theme) ||
defaultTheme
} catch (e) {
// Storage unavailable (incognito/privacy mode) - use default
return defaultTheme
}
})
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light'
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
// Try to persist to localStorage, fall back to sessionStorage
try {
localStorage.setItem(storageKey, theme)
} catch (e) {
// localStorage unavailable (incognito) - use sessionStorage
try {
sessionStorage.setItem(storageKey, theme)
} catch (err) {
// Both unavailable - just update state without persistence
console.warn('Storage unavailable, theme preference will not persist')
}
}
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider')
return context
}
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})
Migration Guide: Hardcoded Colors → CSS Variables
Overview
This guide helps you migrate from hardcoded Tailwind colors (bg-blue-600) to semantic CSS variables (bg-primary).
Benefits:
- Automatic dark mode support
- Consistent color usage
- Single source of truth
- Easy theme customization
- Better accessibility
---
Semantic Color Mapping
| Hardcoded Color | CSS Variable | Use Case |
|---|---|---|
bg-red-* / text-red-* | bg-destructive / text-destructive | Critical issues, errors, delete actions |
bg-green-* / text-green-* | bg-success / text-success | Success states, positive metrics |
bg-yellow-* / text-yellow-* | bg-warning / text-warning | Warnings, moderate issues |
bg-blue-* / text-blue-* | bg-info or bg-primary | Info boxes, primary actions |
bg-gray-* / text-gray-* | bg-muted / text-muted-foreground | Backgrounds, secondary text |
bg-purple-* | bg-info | Remove - use blue instead |
bg-orange-* | bg-warning | Remove - use yellow instead |
bg-emerald-* | bg-success | Remove - use green instead |
---
Migration Patterns
Pattern 1: Solid Backgrounds
❌ Before:
<div className="bg-blue-50 dark:bg-blue-950/20 text-blue-700 dark:text-blue-300">✅ After:
<div className="bg-info/10 text-info">Note: /10 creates 10% opacity
---
Pattern 2: Borders
❌ Before:
<div className="border-2 border-green-200 dark:border-green-800">✅ After:
<div className="border-2 border-success/30">---
Pattern 3: Text Colors
❌ Before:
<span className="text-red-600 dark:text-red-400">✅ After:
<span className="text-destructive">---
Pattern 4: Icons
❌ Before:
<AlertCircle className="text-yellow-500" />✅ After:
<AlertCircle className="text-warning" />---
Pattern 5: Gradients
❌ Before:
<div className="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20">✅ After:
<div className="bg-gradient-to-r from-success/10 to-success/20">---
Step-by-Step Migration
Step 1: Add Semantic Colors to CSS
/* src/index.css */
:root {
/* Add these if not already present */
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(210 40% 98%);
--success: hsl(142.1 76.2% 36.3%);
--success-foreground: hsl(210 40% 98%);
--warning: hsl(38 92% 50%);
--warning-foreground: hsl(222.2 47.4% 11.2%);
--info: hsl(221.2 83.2% 53.3%);
--info-foreground: hsl(210 40% 98%);
}
.dark {
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(210 40% 98%);
--success: hsl(142.1 70.6% 45.3%);
--success-foreground: hsl(222.2 47.4% 11.2%);
--warning: hsl(38 92% 55%);
--warning-foreground: hsl(222.2 47.4% 11.2%);
--info: hsl(217.2 91.2% 59.8%);
--info-foreground: hsl(222.2 47.4% 11.2%);
}
@theme inline {
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
}Step 2: Find Hardcoded Colors
# Search for background colors
grep -r "bg-\(red\|yellow\|blue\|green\|purple\|orange\|pink\|emerald\)-[0-9]" src/
# Search for text colors
grep -r "text-\(red\|yellow\|blue\|green\|purple\|orange\|pink\|emerald\)-[0-9]" src/
# Search for border colors
grep -r "border-\(red\|yellow\|blue\|green\|purple\|orange\|pink\|emerald\)-[0-9]" src/Step 3: Replace Component by Component
Start with high-impact components: 1. Buttons 2. Badges 3. Alert boxes 4. Status indicators 5. Cards
Step 4: Test Both Themes
After each component:
- [ ] Check light mode appearance
- [ ] Check dark mode appearance
- [ ] Verify text contrast
- [ ] Test hover/active states
---
Example: Badge Component
❌ Before:
const severityConfig = {
critical: {
color: 'text-red-500',
bg: 'bg-red-500/10',
border: 'border-red-500/20',
},
warning: {
color: 'text-yellow-500',
bg: 'bg-yellow-500/10',
border: 'border-yellow-500/20',
},
info: {
color: 'text-blue-500',
bg: 'bg-blue-500/10',
border: 'border-blue-500/20',
}
}✅ After:
const severityConfig = {
critical: {
color: 'text-destructive',
bg: 'bg-destructive/10',
border: 'border-destructive/20',
},
warning: {
color: 'text-warning',
bg: 'bg-warning/10',
border: 'border-warning/20',
},
info: {
color: 'text-info',
bg: 'bg-info/10',
border: 'border-info/20',
}
}---
Testing Checklist
After migration:
- [ ] All severity levels (critical/warning/info) visually distinct
- [ ] Text has proper contrast in both light and dark modes
- [ ] No hardcoded color classes remain
- [ ] Hover states work correctly
- [ ] Gradients render smoothly
- [ ] Icons are visible and colored correctly
- [ ] Borders are visible
- [ ] No visual regressions
---
Verification Commands
# Should return 0 results when migration complete
grep -r "text-red-[0-9]" src/components/
grep -r "bg-blue-[0-9]" src/components/
grep -r "border-green-[0-9]" src/components/
# Verify semantic colors are used
grep -r "bg-destructive" src/components/
grep -r "text-success" src/components/---
Performance Impact
Before: Every component has dark: variants
<div className="bg-blue-50 dark:bg-blue-950/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800">After: Single class, CSS handles switching
<div className="bg-info/10 text-info border-info/30">Result:
- 60% fewer CSS classes in markup
- Smaller HTML payload
- Faster rendering
- Easier to maintain
---
Common Pitfalls
1. Forgetting to Map in @theme inline
Variables defined in :root but not mapped → utilities don't exist
2. Wrong Opacity Syntax
❌ bg-success-10 (doesn't work) ✅ bg-success/10 (correct)
3. Mixing Approaches
Don't mix hardcoded and semantic in same component - choose one approach.
4. Not Testing Dark Mode
Always test both themes during migration.
---
Rollback Plan
If migration causes issues:
1. Keep original components in git history 2. Use feature flags to toggle new theme 3. Test with subset of users first 4. Have monitoring for visual regressions
---
Further Customization
After migration, you can easily:
- Add new semantic colors
- Create theme variants (high contrast, etc.)
- Support multiple brand themes
- Implement user-selectable color schemes
All by editing CSS variables - no component changes needed!
Related skills
How it compares
Use tailwind-theme-builder instead of manual Tailwind config editing when shadcn/ui cssVariables and v4 `@theme inline` conventions must align.
FAQ
Where should :root and .dark variables be defined?
Define them at the root level, not inside @layer base, with hsl() wrappers on colour values.
Which Vite integration does v4 require?
Use the @tailwindcss/vite plugin instead of PostCSS and remove tailwind.config.ts.
How does dark mode switch semantic colours?
ThemeProvider toggles the .dark class on html so variables update and utilities like bg-background follow automatically.
Is Tailwind Theme Builder safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.