- 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.25
rem
;
--radius-md
:
0.375
rem
;
--radius-lg
:
0.5
rem
;
--radius-xl
:
0.75
rem
;
/ Animation tokens - keyframes inside @theme are output when referenced by --animate- variables /
--animate-fade-in
:
fade-in
0.2
s
ease-out
;
--animate-fade-out
:
fade-out
0.2
s
ease-in
;
--animate-slide-in
:
slide-in
0.3
s
ease-out
;
--animate-slide-out
:
slide-out
0.3
s
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.5
rem
)
;
opacity
:
0
;
}
to
{
transform
:
translateY
(
0
)
;
opacity
:
1
;
}
}
@keyframes
slide-out
{
from
{
transform
:
translateY
(
0
)
;
opacity
:
1
;
}
to
{
transform
:
translateY
(
-0.5
rem
)
;
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-primary
2. 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
Pattern 5: Native CSS Animations (v4) / In your CSS file - native @starting-style for entry animations / @theme { --animate-dialog-in : dialog-fade-in 0.2 s ease-out ; --animate-dialog-out : dialog-fade-out 0.15 s ease-in ; } @keyframes dialog-fade-in { from { opacity : 0 ; transform : scale ( 0.95 ) translateY ( -0.5 rem ) ; } 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.5 rem ) ; } } / Native popover animations using @starting-style / [ popover ] { transition : opacity 0.2 s , transform 0.2 s , display 0.2 s 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 ) ; } } // components/ui/dialog.tsx - Using native popover API import * 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) // 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.tsx import { 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 // 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" ; Advanced v4 Patterns Custom Utilities with @utility Define reusable custom utilities: / Custom utility for decorative lines / @utility line-t { @apply relative before : absolute before : top-0 before : -left-[ 100 vw ] before : h-px before : w-[ 200 vw ] 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 / 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 @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 @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 @theme { --container-xs : 20 rem ; --container-sm : 24 rem ; --container-md : 28 rem ; --container-lg : 32 rem ; } v3 to v4 Migration Checklist Replace tailwind.config.ts with CSS @theme block Change @tailwind base/components/utilities to @import "tailwindcss" Move color definitions to @theme { --color-: value } Replace darkMode: "class" with @custom-variant dark Move @keyframes inside @theme blocks (ensures keyframes output with theme) Replace require("tailwindcss-animate") with native CSS animations Update h-10 w-10 to size-10 (new utility) Remove forwardRef (React 19 passes ref as prop) Consider OKLCH colors for better color perception Replace custom plugins with @utility directives Best Practices Do's Use @theme blocks - 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-primary not bg-blue-500 Use size- - New shorthand for w- h-* Add accessibility - ARIA attributes, focus states Don'ts Don't use tailwind.config.ts - Use CSS @theme instead Don't use @tailwind directives - Use @import "tailwindcss" Don't use forwardRef - React 19 passes ref as prop Don't use arbitrary values - Extend @theme instead Don't hardcode colors - Use semantic tokens Don't forget dark mode - Test both themes