Tailwind Design System
Build production-ready component libraries with Tailwind CSS using CVA, compound components, design tokens, and theming.
WHAT
Patterns for scalable Tailwind-based design systems:
- Class Variance Authority (CVA) for type-safe variants
- Compound component architecture
- CSS variable-based theming
- Dark mode implementation
- Responsive grid systems
- Animation utilities
WHEN
- Building a component library with Tailwind
- Implementing design tokens and theming
- Creating reusable UI components with variants
- Setting up dark mode
- Standardizing patterns across a codebase
KEYWORDS
tailwind, cva, design system, component library, variants, theming, dark mode, design tokens, shadcn, compound components, tailwind-merge
Related skills: tailwind-v4-shadcn for Tailwind v4 setup and migration
Installation
OpenClaw / Moltbot / Clawbot
npx clawhub@latest install tailwind-design-system
Core Setup
Utility Function
// lib/utils.ts
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Design Token Architecture
Primitive Tokens (abstract)
└── Semantic Tokens (purpose)
└── Component Tokens (specific)
Example:
slate-900 → foreground → card-title-color
Pattern 1: CVA Components
Class Variance Authority for type-safe, variant-based components:
// components/ui/button.tsx
import { cva, type VariantProps } from 'class-variance-authority'
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
// Base styles (always applied)
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
Usage:
<Button variant="destructive" size="lg">Delete</Button>
<Button variant="outline">Cancel</Button>
<Button variant="ghost" size="icon"><Search /></Button>
Pattern 2: Compound Components
Composable components with shared context:
// components/ui/card.tsx
import { cn } from '@/lib/utils'
import { forwardRef } from 'react'
const Card = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
Usage:
<Card>
<CardHeader>
<CardTitle>Account Settings</CardTitle>
<CardDescription>Manage your account preferences</CardDescription>
</CardHeader>
<CardContent>
<form>{/* form fields */}</form>
</CardContent>
<CardFooter>
<Button>Save Changes</Button>
</CardFooter>
</Card>
Pattern 3: Form Components with Validation
// components/ui/input.tsx
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
error?: string
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type, error, ...props }, ref) => {
return (
<div className="relative">
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-destructive focus-visible:ring-destructive',
className
)}
ref={ref}
aria-invalid={!!error}
aria-describedby={error ? `${props.id}-error` : undefined}
{...props}
/>
{error && (
<p id={`${props.id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error}
</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
export { Input }
Pattern 4: Grid System
// components/ui/grid.tsx
import { cn } from '@/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
const gridVariants = cva('grid', {
variants: {
cols: {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
},
gap: {
none: 'gap-0',
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6',
xl: 'gap-8',
},
},
defaultVariants: {
cols: 3,
gap: 'md',
},
})
interface GridProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof gridVariants> {}
export function Grid({ className, cols, gap, ...props }: GridProps) {
return <div className={cn(gridVariants({ cols, gap, className }))} {...props} />
}
// Container component
const containerVariants = cva('mx-auto w-full px-4 sm:px-6 lg:px-8', {
variants: {
size: {
sm: 'max-w-screen-sm',
md: 'max-w-screen-md',
lg: 'max-w-screen-lg',
xl: 'max-w-screen-xl',
'2xl': 'max-w-screen-2xl',
full: 'max-w-full',
},
},
defaultVariants: {
size: 'xl',
},
})
interface ContainerProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof containerVariants> {}
export function Container({ className, size, ...props }: ContainerProps) {
return <div className={cn(containerVariants({ size, className }))} {...props} />
}
Usage:
<Container>
<Grid cols={4} gap="lg">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</Grid>
</Container>
Pattern 5: Dark Mode
Theme Provider
// providers/theme-provider.tsx
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'dark' | 'light' | 'system'
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
resolvedTheme: 'dark' | 'light'
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'theme',
}: {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}) {
const [theme, setTheme] = useState<Theme>(defaultTheme)
const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>('light')
useEffect(() => {
const stored = localStorage.getItem(storageKey) as Theme | null
if (stored) setTheme(stored)
}, [storageKey])
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
const resolved = theme === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
: theme
root.classList.add(resolved)
setResolvedTheme(resolved)
}, [theme])
return (
<ThemeContext.Provider value={{
theme,
setTheme: (t) => { localStorage.setItem(storageKey, t); setTheme(t) },
resolvedTheme,
}}>
{children}
</ThemeContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within ThemeProvider')
return context
}
Theme Toggle
import { Moon, Sun } from 'lucide-react'
import { useTheme } from '@/providers/theme-provider'
import { Button } from '@/components/ui/button'
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}
Animation Utilities
// lib/animations.ts
import { cn } from './utils'
export const fadeIn = 'animate-in fade-in duration-300'
export const fadeOut = 'animate-out fade-out duration-300'
export const slideInFromTop = 'animate-in slide-in-from-top duration-300'
export const slideInFromBottom = 'animate-in slide-in-from-bottom duration-300'
export const zoomIn = 'animate-in zoom-in-95 duration-300'
export const zoomOut = 'animate-out zoom-out-95 duration-300'
// Compound animations
export const modalEnter = cn(fadeIn, zoomIn, 'duration-200')
export const modalExit = cn(fadeOut, zoomOut, 'duration-200')
export const dropdownEnter = cn(fadeIn, slideInFromTop, 'duration-150')
Best Practices
Do
- Use CSS variables for theming (enables runtime switching)
- Compose variants with CVA (type-safe, explicit)
- Use semantic color names (
primarynotblue-500) - Forward refs for composition
- Add accessibility attributes (ARIA, focus states)
- Use
tailwind-mergeto handle class conflicts
Don't
- Use arbitrary values when you can extend the theme
- Nest
@applydeeply (hurts readability) - Skip focus states (keyboard users need them)
- Hardcode colors (use semantic tokens)
- Forget to test dark mode
NEVER
- Use hardcoded colors like
bg-blue-500for semantic purposes (usebg-primary) - Skip focus-visible styles on interactive elements
- Mix arbitrary values with design tokens inconsistently
- Forget
forwardRefon reusable components - Use
!importantto override styles (fix the cascade instead)