Claude-skills tailwind-theme-builder

install
source · Clone the upstream repo
git clone https://github.com/jezweb/claude-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jezweb/claude-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/frontend/skills/tailwind-theme-builder" ~/.claude/skills/jezweb-claude-skills-tailwind-theme-builder && rm -rf "$T"
manifest: plugins/frontend/skills/tailwind-theme-builder/SKILL.md
source content

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

#SymptomCauseFix
1Variables ignored / theme broken
:root
inside
@layer base
Move
:root
and
.dark
to root level
2Dark mode colours not switching
.dark { @theme { } }
Use CSS variables + single
@theme inline
3Colours all black/whiteDouble
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
6shadcn components break
components.json
has config path
Set
"config": ""
(empty string)
7Tailwind not processingUsing PostCSS pluginSwitch to
@tailwindcss/vite
plugin
8
@/
imports fail
Missing path aliasesAdd
paths
to
tsconfig.app.json
9Redundant
dark:
variants
Using
dark:bg-primary-dark
Just use
bg-primary
-- variables handle it
10Hardcoded colours everywhereUsing
bg-blue-600 dark:bg-blue-400
Use semantic tokens:
bg-primary
11Class merging bugsString concatenation for classesUse
cn()
from
@/lib/utils
12Radix Select crashesEmpty string value
value=""
Use
value="placeholder"
13Wrong Tailwind versionInstalled
tailwindcss@^3
Install
tailwindcss@^4.1.0
+
@tailwindcss/vite
14Missing peer depsOnly installed
tailwindcss
Also install
clsx
,
tailwind-merge
,
@types/node
15Broken in dark modeOnly tested light modeTest light, dark, system, and toggle transitions
16Fails WCAG contrastLooks fine visuallyCheck ratios: 4.5:1 normal text, 3:1 large/UI
17Build fails on animation importUsing
tailwindcss-animate
(deprecated)
Use
tw-animate-css
or native CSS animations
18CSS priority issuesDuplicate
@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