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
/* 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
- Design Token Hierarchy
Brand Tokens (abstract) └── Semantic Tokens (purpose) └── Component Tokens (specific)
Example: oklch(45% 0.2 260) → --color-primary → bg-primary
- Component Architecture
Base styles → Variants → Sizes → States → Overrides
Patterns
Pattern 1: CVA (Class Variance Authority) Components
// components/ui/button.tsx import { 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 needed export 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)
// components/ui/card.tsx import { cn } from '@/lib/utils'
// React 19: ref is a regular prop, no forwardRef export 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
// components/ui/input.tsx import { 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.tsx import { 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 + Zod import { 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
// 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', 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 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>
For advanced animation and dark mode patterns, see references/advanced-patterns.md:
-
Pattern 5: Native CSS Animations — dialog @keyframes , native popover API with @starting-style , allow-discrete transitions, and a full DialogContent /DialogOverlay implementation using Radix UI
-
Pattern 6: Dark Mode — ThemeProvider context with localStorage persistence, prefers-color-scheme detection, meta theme-color update, and a ThemeToggle button component
Utility Functions
// lib/utils.ts import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
// Focus ring utility export const focusRing = cn( "focus-visible:outline-none focus-visible:ring-2", "focus-visible:ring-ring focus-visible:ring-offset-2", );
// Disabled utility export const disabled = "disabled:pointer-events-none disabled:opacity-50";
For advanced v4 CSS patterns, the full v3-to-v4 migration checklist, and complete best practices, see references/advanced-patterns.md:
-
Custom @utility — reusable CSS utilities for decorative lines and text gradients
-
Theme modifiers — @theme inline (reference other CSS vars), @theme static (always output), @import "tailwindcss" theme(static)
-
Namespace overrides — clearing default Tailwind color scales with --color-*: initial
-
Semi-transparent variants — color-mix() for alpha scale generation
-
Container queries — --container-* token definitions
-
v3→v4 migration checklist — 10-item checklist covering config, directives, colors, dark mode, animations, React 19 ref changes
-
Best practices — full Do's and Don'ts list