Tailwind Design System (v4)
Build production-ready design systems with Tailwind CSS v4, including CSS-first configuration, design tokens, component variants, responsive patterns, and accessibility.
Note: This skill targets Tailwind CSS v4 (2024+). For v3 projects, refer to the upgrade guide.
When to Use This Skill
- Creating a component library with Tailwind v4
- Implementing design tokens and theming with CSS-first configuration
- Building responsive and accessible components
- Standardizing UI patterns across a codebase
- Migrating from Tailwind v3 to v4
- Setting up dark mode with native CSS features
Key v4 Changes
| v3 Pattern | v4 Pattern |
|---|---|
tailwind.config.ts | @theme in CSS |
@tailwind base/components/utilities | @import "tailwindcss" |
darkMode: "class" | @custom-variant dark (&:where(.dark, .dark *)) |
theme.extend.colors | @theme { --color-*: value } |
require("tailwindcss-animate") | CSS @keyframes in @theme + @starting-style for entry animations |
Quick Start
css
/* app.css - Tailwind v4 CSS-first configuration */@import "tailwindcss";
/* Define your theme with @theme */@theme { /* Semantic color tokens using OKLCH for better color perception */ --color-background: oklch(100% 0 0); --color-foreground: oklch(14.5% 0.025 264);
--color-primary: oklch(14.5% 0.025 264); --color-primary-foreground: oklch(98% 0.01 264);
--color-secondary: oklch(96% 0.01 264); --color-secondary-foreground: oklch(14.5% 0.025 264);
--color-muted: oklch(96% 0.01 264); --color-muted-foreground: oklch(46% 0.02 264);
--color-accent: oklch(96% 0.01 264); --color-accent-foreground: oklch(14.5% 0.025 264);
--color-destructive: oklch(53% 0.22 27); --color-destructive-foreground: oklch(98% 0.01 264);
--color-border: oklch(91% 0.01 264); --color-ring: oklch(14.5% 0.025 264);
--color-card: oklch(100% 0 0); --color-card-foreground: oklch(14.5% 0.025 264);
/* Ring offset for focus states */ --color-ring-offset: oklch(100% 0 0);
/* Radius tokens */ --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 0.75rem;
/* Animation tokens - keyframes inside @theme are output when referenced by --animate-* variables */ --animate-fade-in: fade-in 0.2s ease-out; --animate-fade-out: fade-out 0.2s ease-in; --animate-slide-in: slide-in 0.3s ease-out; --animate-slide-out: slide-out 0.3s ease-in;
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes fade-out { from { opacity: 1; } to { opacity: 0; } }
@keyframes slide-in { from { transform: translateY(-0.5rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes slide-out { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-0.5rem); opacity: 0; } }}
/* Dark mode variant - use @custom-variant for class-based dark mode */@custom-variant dark (&:where(.dark, .dark *));
/* Dark mode theme overrides */.dark { --color-background: oklch(14.5% 0.025 264); --color-foreground: oklch(98% 0.01 264);
--color-primary: oklch(98% 0.01 264); --color-primary-foreground: oklch(14.5% 0.025 264);
--color-secondary: oklch(22% 0.02 264); --color-secondary-foreground: oklch(98% 0.01 264);
--color-muted: oklch(22% 0.02 264); --color-muted-foreground: oklch(65% 0.02 264);
--color-accent: oklch(22% 0.02 264); --color-accent-foreground: oklch(98% 0.01 264);
--color-destructive: oklch(42% 0.15 27); --color-destructive-foreground: oklch(98% 0.01 264);
--color-border: oklch(22% 0.02 264); --color-ring: oklch(83% 0.02 264);
--color-card: oklch(14.5% 0.025 264); --color-card-foreground: oklch(98% 0.01 264);
--color-ring-offset: oklch(14.5% 0.025 264);}
/* Base styles */@layer base { * { @apply border-border; }
body { @apply bg-background text-foreground antialiased; }}Core Concepts
1. Design Token Hierarchy
Brand Tokens (abstract) └── Semantic Tokens (purpose) └── Component Tokens (specific)
Example: oklch(45% 0.2 260) → --color-primary → bg-primary2. Component Architecture
Base styles → Variants → Sizes → States → OverridesPatterns
Pattern 1: CVA (Class Variance Authority) Components
typescript
// components/ui/button.tsximport { Slot } from '@radix-ui/react-slot'import { cva, type VariantProps } from 'class-variance-authority'import { cn } from '@/lib/utils'
const buttonVariants = cva( // Base styles - v4 uses native CSS variables '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 focus-visible:ring-offset-2 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-border 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: 'size-10', }, }, defaultVariants: { variant: 'default', size: 'default', }, })
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> { asChild?: boolean}
// React 19: No forwardRef neededexport function Button({ className, variant, size, asChild = false, ref, ...props}: ButtonProps & { ref?: React.Ref<HTMLButtonElement> }) { const Comp = asChild ? Slot : 'button' return ( <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /> )}
// Usage<Button variant="destructive" size="lg">Delete</Button><Button variant="outline">Cancel</Button><Button asChild><Link href="/home">Home</Link></Button>Pattern 2: Compound Components (React 19)
typescript
// components/ui/card.tsximport { cn } from '@/lib/utils'
// React 19: ref is a regular prop, no forwardRefexport function Card({ className, ref, ...props}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) { return ( <div ref={ref} className={cn( 'rounded-lg border border-border bg-card text-card-foreground shadow-sm', className )} {...props} /> )}
export function CardHeader({ className, ref, ...props}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) { return ( <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} /> )}
export function CardTitle({ className, ref, ...props}: React.HTMLAttributes<HTMLHeadingElement> & { ref?: React.Ref<HTMLHeadingElement> }) { return ( <h3 ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} /> )}
export function CardDescription({ className, ref, ...props}: React.HTMLAttributes<HTMLParagraphElement> & { ref?: React.Ref<HTMLParagraphElement> }) { return ( <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> )}
export function CardContent({ className, ref, ...props}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) { return ( <div ref={ref} className={cn('p-6 pt-0', className)} {...props} /> )}
export function CardFooter({ className, ref, ...props}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) { return ( <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} /> )}
// Usage<Card> <CardHeader> <CardTitle>Account</CardTitle> <CardDescription>Manage your account settings</CardDescription> </CardHeader> <CardContent> <form>...</form> </CardContent> <CardFooter> <Button>Save</Button> </CardFooter></Card>Pattern 3: Form Components
typescript
// components/ui/input.tsximport { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { error?: string ref?: React.Ref<HTMLInputElement>}
export function Input({ className, type, error, ref, ...props }: InputProps) { return ( <div className="relative"> <input type={type} className={cn( 'flex h-10 w-full rounded-md border border-border 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> )}
// components/ui/label.tsximport { cva, type VariantProps } from 'class-variance-authority'
const labelVariants = cva( 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70')
export function Label({ className, ref, ...props}: React.LabelHTMLAttributes<HTMLLabelElement> & { ref?: React.Ref<HTMLLabelElement> }) { return ( <label ref={ref} className={cn(labelVariants(), className)} {...props} /> )}
// Usage with React Hook Form + Zodimport { useForm } from 'react-hook-form'import { zodResolver } from '@hookform/resolvers/zod'import { z } from 'zod'
const schema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'),})
function LoginForm() { const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(schema), })
return ( <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <div className="space-y-2"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" {...register('email')} error={errors.email?.message} /> </div> <div className="space-y-2"> <Label htmlFor="password">Password</Label> <Input id="password" type="password" {...register('password')} error={errors.password?.message} /> </div> <Button type="submit" className="w-full">Sign In</Button> </form> )}Pattern 4: Responsive Grid System
typescript
// components/ui/grid.tsximport { 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', 5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5', 6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6', }, 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 componentconst 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: Native CSS Animations (v4)
css
/* In your CSS file - native @starting-style for entry animations */@theme { --animate-dialog-in: dialog-fade-in 0.2s ease-out; --animate-dialog-out: dialog-fade-out 0.15s ease-in;}
@keyframes dialog-fade-in { from { opacity: 0; transform: scale(0.95) translateY(-0.5rem); } to { opacity: 1; transform: scale(1) translateY(0); }}
@keyframes dialog-fade-out { from { opacity: 1; transform: scale(1) translateY(0); } to { opacity: 0; transform: scale(0.95) translateY(-0.5rem); }}
/* Native popover animations using @starting-style */[popover] { transition: opacity 0.2s, transform 0.2s, display 0.2s allow-discrete; opacity: 0; transform: scale(0.95);}
[popover]:popover-open { opacity: 1; transform: scale(1);}
@starting-style { [popover]:popover-open { opacity: 0; transform: scale(0.95); }}typescript
// components/ui/dialog.tsx - Using native popover APIimport * as DialogPrimitive from '@radix-ui/react-dialog'import { cn } from '@/lib/utils'
const DialogPortal = DialogPrimitive.Portal
export function DialogOverlay({ className, ref, ...props}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & { ref?: React.Ref<HTMLDivElement>}) { return ( <DialogPrimitive.Overlay ref={ref} className={cn( 'fixed inset-0 z-50 bg-black/80', 'data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out', className )} {...props} /> )}
export function DialogContent({ className, children, ref, ...props}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { ref?: React.Ref<HTMLDivElement>}) { return ( <DialogPortal> <DialogOverlay /> <DialogPrimitive.Content ref={ref} className={cn( 'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-background p-6 shadow-lg sm:rounded-lg', 'data-[state=open]:animate-dialog-in data-[state=closed]:animate-dialog-out', className )} {...props} > {children} </DialogPrimitive.Content> </DialogPortal> )}Pattern 6: Dark Mode with CSS (v4)
typescript
// providers/ThemeProvider.tsx - Simplified for v4'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 = 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)
// Update meta theme-color for mobile browsers const metaThemeColor = document.querySelector('meta[name="theme-color"]') if (metaThemeColor) { metaThemeColor.setAttribute('content', resolved === 'dark' ? '#09090b' : '#ffffff') } }, [theme])
return ( <ThemeContext.Provider value={{ theme, setTheme: (newTheme) => { localStorage.setItem(storageKey, newTheme) setTheme(newTheme) }, resolvedTheme, }}> {children} </ThemeContext.Provider> )}
export const useTheme = () => { const context = useContext(ThemeContext) if (!context) throw new Error('useTheme must be used within ThemeProvider') return context}
// components/ThemeToggle.tsximport { Moon, Sun } from 'lucide-react'import { useTheme } from '@/providers/ThemeProvider'
export function ThemeToggle() { const { resolvedTheme, setTheme } = useTheme()
return ( <Button variant="ghost" size="icon" onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')} > <Sun className="size-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Moon className="absolute size-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <span className="sr-only">Toggle theme</span> </Button> )}Utility Functions
typescript
// lib/utils.tsimport { type ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
// Focus ring utilityexport const focusRing = cn( "focus-visible:outline-none focus-visible:ring-2", "focus-visible:ring-ring focus-visible:ring-offset-2",);
// Disabled utilityexport const disabled = "disabled:pointer-events-none disabled:opacity-50";Advanced v4 Patterns
Custom Utilities with @utility
Define reusable custom utilities:
css
/* Custom utility for decorative lines */@utility line-t { @apply relative before:absolute before:top-0 before:-left-[100vw] before:h-px before:w-[200vw] before:bg-gray-950/5 dark:before:bg-white/10;}
/* Custom utility for text gradients */@utility text-gradient { @apply bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent;}Theme Modifiers
css
/* Use @theme inline when referencing other CSS variables */@theme inline { --font-sans: var(--font-inter), system-ui;}
/* Use @theme static to always generate CSS variables (even when unused) */@theme static { --color-brand: oklch(65% 0.15 240);}
/* Import with theme options */@import "tailwindcss" theme(static);Namespace Overrides
css
@theme { /* Clear all default colors and define your own */ --color-*: initial; --color-white: #fff; --color-black: #000; --color-primary: oklch(45% 0.2 260); --color-secondary: oklch(65% 0.15 200);
/* Clear ALL defaults for a minimal setup */ /* --*: initial; */}Semi-transparent Color Variants
css
@theme { /* Use color-mix() for alpha variants */ --color-primary-50: color-mix(in oklab, var(--color-primary) 5%, transparent); --color-primary-100: color-mix( in oklab, var(--color-primary) 10%, transparent ); --color-primary-200: color-mix( in oklab, var(--color-primary) 20%, transparent );}Container Queries
css
@theme { --container-xs: 20rem; --container-sm: 24rem; --container-md: 28rem; --container-lg: 32rem;}v3 to v4 Migration Checklist
- Replace
tailwind.config.tswith CSS@themeblock - Change
@tailwind base/components/utilitiesto@import "tailwindcss" - Move color definitions to
@theme { --color-*: value } - Replace
darkMode: "class"with@custom-variant dark - Move
@keyframesinside@themeblocks (ensures keyframes output with theme) - Replace
require("tailwindcss-animate")with native CSS animations - Update
h-10 w-10tosize-10(new utility) - Remove
forwardRef(React 19 passes ref as prop) - Consider OKLCH colors for better color perception
- Replace custom plugins with
@utilitydirectives
Best Practices
Do's
- Use
@themeblocks - CSS-first configuration is v4's core pattern - Use OKLCH colors - Better perceptual uniformity than HSL
- Compose with CVA - Type-safe variants
- Use semantic tokens -
bg-primarynotbg-blue-500 - Use
size-*- New shorthand forw-* h-* - Add accessibility - ARIA attributes, focus states
Don'ts
- Don't use
tailwind.config.ts- Use CSS@themeinstead - Don't use
@tailwinddirectives - Use@import "tailwindcss" - Don't use
forwardRef- React 19 passes ref as prop - Don't use arbitrary values - Extend
@themeinstead - Don't hardcode colors - Use semantic tokens
- Don't forget dark mode - Test both themes

