tailwind-theme-builder

$npx mdskill add jezweb/claude-skills/tailwind-theme-builder

Set up Tailwind v4 + shadcn/ui themed UI with dark mode.

  • Solves Tailwind v4 theming, dark mode, and migration issues.
  • Depends on Tailwind v4, shadcn/ui, and CSS variables.
  • Uses a four-step pattern: define, map, toggle, verify.
  • Delivers configured CSS, theme provider, and working components.

SKILL.md

.github/skills/tailwind-theme-builderView on GitHub ↗
---
name: tailwind-theme-builder
description: "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 the user mentions Tailwind v4, setting up Tailwind theming, shadcn/ui colours, dark mode, or troubleshooting colours not working, tw-animate-css errors, @theme inline conflicts, @apply breaking after upgrade, or v3 → v4 migration issues."
compatibility: claude-code-only
---

# 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

```bash
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:

```typescript
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:**

```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:

```typescript
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:

```bash
pnpm dlx shadcn@latest add dropdown-menu
```

```typescript
// 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

```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:
```css
@layer base {
  :root { --background: hsl(0 0% 100%); }
}
```

CORRECT:
```css
: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:
```css
@theme { --color-primary: hsl(0 0% 0%); }
.dark { @theme { --color-primary: hsl(0 0% 100%); } }
```

CORRECT:
```css
: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:
```css
:root { --background: hsl(0 0% 100%); }
/* No @theme inline block -- bg-background won't exist */
```

CORRECT:
```css
:root { --background: hsl(0 0% 100%); }
@theme inline { --color-background: var(--background); }
```

**#7 -- PostCSS vs Vite plugin**

WRONG:
```typescript
export default defineConfig({
  css: { postcss: './postcss.config.js' }  // Old v3 way
})
```

CORRECT:
```typescript
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
  plugins: [react(), tailwindcss()]  // v4 way
})
```

**#8 -- Path aliases**

Add to `tsconfig.app.json`:
```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**

```json
{
  "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:
```css
@layer base { body { background-color: var(--background); } }
@layer base { * { border-color: hsl(var(--border)); } }  /* duplicate from shadcn */
```

CORRECT:
```css
@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-mode

More from jezweb/claude-skills

SkillDescription
ai-image-generatorGenerate AI images using Gemini or GPT APIs directly. Covers model selection (Gemini for scenes; GPT Image 2 for text rendering, batch variations, multi-reference compositing; GPT Image 1.5 for transparent icons), the 5-part prompting framework, API calling patterns, multi-turn editing, and quality assurance. Produces photorealistic scenes, icons, illustrations, OG images, posters, infographics, and product shots. Use when building websites that need images, creating marketing assets, or generating visual content. Triggers: 'generate image', 'ai image', 'create hero image', 'make an icon', 'generate illustration', 'create og image', 'poster', 'infographic', 'image variations', 'gpt-image-2', 'ai art', 'image generation'.
app-docsGenerate complete user documentation for a web app with screenshots. Browses the app via browser automation, screenshots every screen, and produces a structured user guide with step-by-step instructions, annotated screenshots, workflow diagrams, and reference tables. Supports quick (key screens), standard (all pages), thorough (every state and flow), and exhaustive (publishable documentation suite). Triggers: 'document the app', 'user guide', 'app documentation', 'screenshot docs', 'generate user docs', 'help docs', 'how-to guide', 'write the docs'.
aussie-business-englishAustralian business English for professional writing — warm, direct, EN-AU spelling (colour, organise, centre), no filler words. Use whenever the user is writing for an Australian audience: emails, chat messages, proposals, client communications, blog posts, web copy, or any business writing. Apply to drafting, editing, and tone-checking professional text.
award-applicationWrite compelling award submissions, grant applications, and competition entries. Maps achievements to selection criteria using evidence-based narratives. Use whenever the user is applying for a business award (Telstra, chamber of commerce, industry awards), a grant, a competition, or any submission demonstrating merit against defined criteria.
cloudflare-apiHit the Cloudflare REST API directly for operations that wrangler and MCP can't handle well. Bulk DNS, custom hostnames, email routing, cache purge, WAF rules, redirect rules, zone settings, Worker routes, D1 cross-database queries, R2 bulk operations, KV bulk read/write, Vectorize queries, Queues, and fleet-wide resource audits. Produces curl commands or scripts. Triggers: 'cloudflare api', 'bulk dns', 'custom hostname', 'email routing', 'cache purge', 'waf rule', 'd1 query', 'r2 bucket', 'kv bulk', 'vectorize query', 'audit resources', 'fleet operation'.
cloudflare-worker-builderScaffold and deploy Cloudflare Workers with Hono routing, Vite plugin, and Static Assets. Describe project, scaffold structure, configure bindings, deploy. Use whenever the user wants to create a Worker project, set up Hono on Cloudflare, configure D1 / R2 / KV / Queues bindings, or troubleshoot Worker export syntax, API route conflicts, HMR issues, or deployment failures.
codex-reviewRun an independent code review using the OpenAI Codex CLI in headless mode. Gets a second opinion from a different model family (the current Codex models) on recent changes, a PR, a commit, or the whole app — covering bugs, regressions, security, data consistency, UX/state bugs, performance risks, and testing gaps. Saves a severity-prioritised report to .jez/reviews/. Triggers: 'codex review', 'review with codex', 'independent code review', 'what does codex think', 'get codex to review'.
color-paletteGenerate complete, accessible colour palettes from a single brand hex. Produces 11-shade scale (50-950), semantic tokens, dark mode variants, Tailwind v4 CSS output, WCAG contrast checks. Use whenever the user supplies a brand hex and asks for a palette, mentions setting up a design system, wants Tailwind theme colours from a brand colour, or asks to check colour accessibility / contrast.
d1-drizzle-schemaGenerate Drizzle ORM schemas for Cloudflare D1 databases with correct D1-specific patterns. Produces schema files, migration commands, type exports, and DATABASE_SCHEMA.md documentation. Handles D1 quirks: foreign keys always enforced, no native BOOLEAN/DATETIME types, 100 bound parameter limit, JSON stored as TEXT. Use when creating a new database, adding tables, or scaffolding a D1 data layer.
d1-migrationCloudflare D1 migration workflow: generate with Drizzle, inspect SQL for gotchas, apply to local and remote, fix stuck migrations, handle partial failures. Use when running migrations, fixing migration errors, or setting up D1 schemas.