
Shadcn Patterns
- 25 installs
- 213 repo stars
- Updated August 4, 2026
- yonatangross/orchestkit
Helps with ai & agent building tasks.
About
shadcn-patterns is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- shadcn-patterns
- AI & Agent Building
- AI-coding skill
Shadcn Patterns by the numbers
- 25 all-time installs (skills.sh)
- Ranked #9,800 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yonatangross/orchestkit --skill shadcn-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 25 |
|---|---|
| repo stars | ★ 213 |
| Last updated | August 4, 2026 |
| Repository | yonatangross/orchestkit ↗ |
What it does
Helps with ai & agent building tasks.
Files
shadcn/ui Patterns
Beautifully designed, accessible components you own and customize.
Core Pattern: CVA (Class Variance Authority)
Declarative, type-safe variant definitions:
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
// Base classes (always applied)
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3',
lg: 'h-11 px-8',
icon: 'h-10 w-10',
},
},
compoundVariants: [
{ variant: 'outline', size: 'lg', className: 'border-2' },
],
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
// Type-safe props
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}Core Pattern: cn() Utility
Combines clsx + tailwind-merge for conflict resolution:
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Usage - later classes win
cn('px-4 py-2', 'px-6') // => 'py-2 px-6'
cn('text-red-500', condition && 'text-blue-500')OKLCH Theming (2026 Standard)
Modern perceptually uniform color space:
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
}Why OKLCH?
- Perceptually uniform (equal steps look equal)
- Better dark mode contrast
- Wide gamut support
- Format:
oklch(lightness chroma hue)
Component Extension Strategy
Wrap, don't modify source:
import { Button as ShadcnButton } from '@/components/ui/button'
// Extend with new variants
const Button = React.forwardRef<
React.ElementRef<typeof ShadcnButton>,
React.ComponentPropsWithoutRef<typeof ShadcnButton> & {
loading?: boolean
}
>(({ loading, children, disabled, ...props }, ref) => (
<ShadcnButton ref={ref} disabled={disabled || loading} {...props}>
{loading && <Spinner className="mr-2" />}
{children}
</ShadcnButton>
))Quick Reference
# Add components
npx shadcn@latest add button
npx shadcn@latest add dialog
# Initialize in project
npx shadcn@latest initKey Decisions
| Decision | Recommendation |
|---|---|
| Color format | OKLCH for perceptually uniform theming |
| Class merging | Always use cn() for Tailwind conflicts |
| Extending components | Wrap, don't modify source files |
| Variants | Use CVA for type-safe multi-axis variants |
Related Skills
radix-primitives- Underlying accessibility primitivesdesign-system-starter- Design system patternsbiome-linting- Code quality for components
References
- CVA Variant System - CVA patterns
- OKLCH Theming - Modern color space
- cn() Utility - Class merging
- Component Extension - Extending components
- Dark Mode - next-themes integration
shadcn/ui Setup Checklist
Complete setup and configuration checklist.
Initial Setup
- [ ] Initialize shadcn/ui:
npx shadcn@latest init - [ ] Select style (New York or Default)
- [ ] Select base color
- [ ] Configure CSS variables: Yes
- [ ] Configure
components.jsongenerated
File Structure Verification
- [ ]
components.jsoncreated at root - [ ]
lib/utils.tscreated withcn()function - [ ]
components/ui/directory created - [ ] CSS variables added to
globals.cssorapp.css - [ ] Dark mode configured via CSS (Tailwind v4 CSS-first approach)
Dependencies Installed
- [ ]
class-variance-authorityfor variants - [ ]
clsxfor conditional classes - [ ]
tailwind-mergefor class merging - [ ]
radix-uiunified package (or individual@radix-ui/react-*) - [ ]
lucide-reactfor icons (optional)
Tailwind Configuration (v4 CSS-First)
/* app.css or globals.css */
@import "tailwindcss";
/* Dark mode via CSS variables - no tailwind.config.js needed */
/* Tailwind v4 auto-detects content files */- [ ]
@import "tailwindcss"in CSS entry file - [ ] CSS variables define theme tokens
- [ ] No
tailwind.config.jsneeded (Tailwind v4 auto-detects content)
CSS Variables
- [ ] Light mode variables defined in
:root - [ ] Dark mode variables defined in
.dark - [ ] All semantic colors defined:
- [ ]
--background,--foreground - [ ]
--card,--card-foreground - [ ]
--popover,--popover-foreground - [ ]
--primary,--primary-foreground - [ ]
--secondary,--secondary-foreground - [ ]
--muted,--muted-foreground - [ ]
--accent,--accent-foreground - [ ]
--destructive,--destructive-foreground - [ ]
--border,--input,--ring - [ ]
--radius
Adding Components
# Add individual components
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add dialog
# Add multiple components
npx shadcn@latest add button card input label- [ ] Add commonly used components
- [ ] Verify components render correctly
- [ ] Test dark mode toggle
Dark Mode Setup
- [ ] Install
next-themes:npm install next-themes - [ ] Create ThemeProvider wrapper
- [ ] Add provider to root layout
- [ ] Add
suppressHydrationWarningto<html> - [ ] Create theme toggle component
- [ ] Test theme persistence
TypeScript Configuration
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}- [ ] Path alias
@/*configured - [ ] Types resolve correctly
Testing Checklist
- [ ] Button variants render correctly
- [ ] Dark mode switches properly
- [ ] No hydration mismatches
- [ ] Keyboard navigation works
- [ ] Focus states visible
- [ ] Responsive at all breakpoints
Common Issues
Hydration Mismatch
// Wrap theme-dependent content
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return nullMissing CSS Variables
- Check
globals.cssis imported in layout - Verify variable names match exactly
Tailwind Classes Not Applying
- Verify
@import "tailwindcss"in CSS entry file - Restart dev server after config changes
Recommended First Components
1. button - Foundation for CTAs 2. input + label - Form basics 3. card - Content containers 4. dialog - Modals 5. dropdown-menu - Actions menu 6. toast - Notifications
cn() Utility Patterns
Class merging with tailwind-merge and clsx.
Setup
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}What It Solves
Problem: Tailwind Class Conflicts
// Without cn() - px-4 and px-6 both apply (unpredictable)
<div className={`px-4 ${props.className}`}>
// If props.className = "px-6", result is "px-4 px-6" (conflict!)
// With cn() - px-6 wins (later class wins)
<div className={cn('px-4', props.className)}>
// Result: "px-6" (clean, predictable)Common Patterns
Conditional Classes
cn(
'base-class',
isActive && 'active-class',
isDisabled && 'disabled-class'
)
// Falsy values are filtered outObject Syntax
cn({
'bg-blue-500': variant === 'primary',
'bg-gray-500': variant === 'secondary',
'opacity-50 cursor-not-allowed': disabled,
})With CVA Variants
cn(buttonVariants({ variant, size }), className)Array of Classes
cn([
'flex items-center',
'gap-2',
'p-4',
])Real-World Component Examples
Button with Override Support
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
className={cn(
// Base styles
'inline-flex items-center justify-center rounded-md font-medium',
// Variant styles from CVA
buttonVariants({ variant, size }),
// Consumer overrides (wins over variants)
className
)}
ref={ref}
{...props}
/>
)
)Card with Conditional Styling
function Card({ className, elevated, interactive, ...props }) {
return (
<div
className={cn(
// Base
'rounded-lg border bg-card text-card-foreground',
// Conditional
elevated && 'shadow-lg',
interactive && 'cursor-pointer hover:bg-accent transition-colors',
// Overrides
className
)}
{...props}
/>
)
}Input with States
function Input({ className, error, ...props }) {
return (
<input
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
'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',
'disabled:cursor-not-allowed disabled:opacity-50',
// Error state
error && 'border-destructive focus-visible:ring-destructive',
className
)}
{...props}
/>
)
}Order Matters
// Classes are processed left to right
// Later classes override earlier ones for the same property
cn('text-red-500', 'text-blue-500')
// Result: "text-blue-500"
cn('p-4', 'p-2', 'p-8')
// Result: "p-8"
cn('text-sm md:text-base', 'text-lg')
// Result: "text-lg md:text-base"
// (base overridden, responsive preserved)With Responsive Classes
cn(
'grid grid-cols-1',
'md:grid-cols-2',
'lg:grid-cols-3',
fullWidth && 'lg:grid-cols-4'
)Performance Notes
twMergeis optimized and fast for typical use- Avoid calling cn() in loops with dynamic classes
- For static classes, regular string concatenation is fine
// ✅ Good - cn() handles conflicts
cn(baseClasses, props.className)
// ✅ Good - no conflicts possible
`${staticClass} ${anotherStatic}`
// ⚠️ Avoid - cn() in hot loop
items.map(item => cn(classes, item.className)) // Consider memoizationComponent Extension Patterns
Extending shadcn/ui components without modifying source.
Principle: Wrap, Don't Modify
shadcn/ui components are meant to be copied and owned. However, when extending:
1. Wrap the original component 2. Forward refs correctly 3. Preserve the variant system 4. Add new functionality as props
Basic Extension: Adding Props
import { Button as ShadcnButton } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
interface ExtendedButtonProps
extends React.ComponentPropsWithoutRef<typeof ShadcnButton> {
loading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ExtendedButtonProps>(
({ loading, disabled, children, ...props }, ref) => (
<ShadcnButton
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</ShadcnButton>
)
)
Button.displayName = 'Button'
export { Button }Adding New Variants
import { cva, type VariantProps } from 'class-variance-authority'
import { Button as ShadcnButton } from '@/components/ui/button'
import { cn } from '@/lib/utils'
// Extended variant system
const extendedButtonVariants = cva('', {
variants: {
glow: {
true: 'shadow-lg shadow-primary/25 hover:shadow-primary/40',
false: '',
},
pulse: {
true: 'animate-pulse',
false: '',
},
},
defaultVariants: {
glow: false,
pulse: false,
},
})
interface ExtendedButtonProps
extends React.ComponentPropsWithoutRef<typeof ShadcnButton>,
VariantProps<typeof extendedButtonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ExtendedButtonProps>(
({ className, glow, pulse, ...props }, ref) => (
<ShadcnButton
ref={ref}
className={cn(extendedButtonVariants({ glow, pulse }), className)}
{...props}
/>
)
)Composition: Combining Components
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface ConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
onConfirm: () => void
onCancel?: () => void
confirmText?: string
cancelText?: string
variant?: 'default' | 'destructive'
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
onConfirm,
onCancel,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'default',
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm()
onOpenChange(false)
}
const handleCancel = () => {
onCancel?.()
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
{cancelText}
</Button>
<Button
variant={variant === 'destructive' ? 'destructive' : 'default'}
onClick={handleConfirm}
>
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Polymorphic Extension with asChild
import { Button } from '@/components/ui/button'
import { Slot } from '@radix-ui/react-slot'
interface IconButtonProps
extends React.ComponentPropsWithoutRef<typeof Button> {
icon: React.ReactNode
label: string // For accessibility
}
const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
({ icon, label, asChild, ...props }, ref) => {
return (
<Button
ref={ref}
size="icon"
aria-label={label}
asChild={asChild}
{...props}
>
{asChild ? (
<Slot>{icon}</Slot>
) : (
icon
)}
</Button>
)
}
)Form Field Wrapper
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
interface FormFieldProps extends React.ComponentPropsWithoutRef<typeof Input> {
label: string
error?: string
description?: string
}
const FormField = React.forwardRef<HTMLInputElement, FormFieldProps>(
({ label, error, description, className, id, ...props }, ref) => {
const inputId = id || label.toLowerCase().replace(/\s+/g, '-')
return (
<div className={cn('space-y-2', className)}>
<Label htmlFor={inputId}>{label}</Label>
<Input
ref={ref}
id={inputId}
aria-describedby={error ? `${inputId}-error` : undefined}
aria-invalid={!!error}
className={cn(error && 'border-destructive')}
{...props}
/>
{description && !error && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{error && (
<p id={`${inputId}-error`} className="text-sm text-destructive">
{error}
</p>
)}
</div>
)
}
)Best Practices
1. Always forward refs - Components may need ref access 2. Preserve displayName - Helps with debugging 3. Type props explicitly - Use ComponentPropsWithoutRef 4. Keep variants compatible - Don't break existing API 5. Document extensions - Make custom props discoverable
CVA Variant System
Type-safe, declarative component variants with Class Variance Authority.
Core Pattern
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
// Base classes (always applied)
'inline-flex items-center justify-center 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',
},
}
)Type-Safe Props
import { cn } from '@/lib/utils'
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)Compound Variants
Apply classes when multiple variants are combined:
const alertVariants = cva(
'relative w-full rounded-lg border p-4',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive: 'border-destructive/50 text-destructive',
success: 'border-green-500/50 text-green-700',
},
size: {
default: 'text-sm',
lg: 'text-base p-6',
},
},
compoundVariants: [
// When variant=destructive AND size=lg, add extra styles
{
variant: 'destructive',
size: 'lg',
className: 'border-2 font-semibold',
},
// Multiple variants can match
{
variant: ['destructive', 'success'],
size: 'lg',
className: 'shadow-lg',
},
],
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)Boolean Variants
const cardVariants = cva(
'rounded-lg border bg-card text-card-foreground',
{
variants: {
elevated: {
true: 'shadow-lg',
false: 'shadow-none',
},
interactive: {
true: 'cursor-pointer hover:bg-accent transition-colors',
false: '',
},
},
defaultVariants: {
elevated: false,
interactive: false,
},
}
)
// Usage
<Card elevated interactive>Click me</Card>Extending Variants
// Base badge variants
const badgeVariants = cva(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
outline: 'border text-foreground',
},
},
defaultVariants: { variant: 'default' },
}
)
// Extended with status colors
const statusBadgeVariants = cva(
badgeVariants({ variant: 'outline' }), // Use base as starting point
{
variants: {
status: {
pending: 'border-yellow-500 text-yellow-700 bg-yellow-50',
active: 'border-green-500 text-green-700 bg-green-50',
inactive: 'border-gray-500 text-gray-700 bg-gray-50',
error: 'border-red-500 text-red-700 bg-red-50',
},
},
defaultVariants: { status: 'pending' },
}
)With Responsive Variants
CVA doesn't handle responsive directly, but combine with Tailwind:
const layoutVariants = cva('grid gap-4', {
variants: {
columns: {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
},
},
defaultVariants: { columns: 1 },
})Best Practices
1. Keep variants focused: Each variant should have a single responsibility 2. Use compound variants sparingly: Only for complex combinations 3. Default variants: Always set sensible defaults 4. Type exports: Export VariantProps type for consumers 5. Consistent naming: variant for style, size for dimensions
Anti-Patterns
// ❌ Don't mix styling and behavior
const badVariants = cva('...', {
variants: {
onClick: { /* This should be a prop, not a variant */ }
}
})
// ❌ Don't duplicate Tailwind breakpoints in variants
const badVariants = cva('...', {
variants: {
mobilePadding: { /* Use responsive Tailwind classes instead */ }
}
})
// ✅ Keep variants about visual presentation
const goodVariants = cva('...', {
variants: {
variant: { /* visual style */ },
size: { /* dimensions */ },
}
})Dark Mode Toggle
Theme switching with next-themes and shadcn/ui.
Setup with next-themes
1. Install
npm install next-themes2. Add ThemeProvider
// app/providers.tsx
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
)
}3. Wrap App
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}Toggle Component (Dropdown)
'use client'
import * as React from 'react'
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
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>
)
}Toggle Component (Simple Button)
'use client'
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@/components/ui/button'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === '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>
)
}Toggle Component (Switch)
'use client'
import { useTheme } from 'next-themes'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Moon, Sun } from 'lucide-react'
export function ThemeSwitch() {
const { theme, setTheme } = useTheme()
const isDark = theme === 'dark'
return (
<div className="flex items-center gap-2">
<Sun className="h-4 w-4" />
<Switch
id="theme-switch"
checked={isDark}
onCheckedChange={(checked) => setTheme(checked ? 'dark' : 'light')}
/>
<Moon className="h-4 w-4" />
<Label htmlFor="theme-switch" className="sr-only">
Toggle dark mode
</Label>
</div>
)
}Handling Hydration
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
export function ThemeAwareComponent() {
const { theme, resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Avoid hydration mismatch
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return <Skeleton /> // Or null
}
// Safe to use theme now
return (
<div>
Current theme: {resolvedTheme}
</div>
)
}CSS Variables Setup
Ensure your CSS supports both themes:
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
/* ... other light mode variables */
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
/* ... other dark mode variables */
}Configuration Options
<NextThemesProvider
attribute="class" // Use class strategy
defaultTheme="system" // Default to system preference
enableSystem // Enable system preference detection
disableTransitionOnChange // Prevent flash on theme change
storageKey="theme" // localStorage key
themes={['light', 'dark', 'system']} // Available themes
/>Tailwind v4 Dark Mode
In Tailwind CSS v4, dark mode uses a CSS-first approach. The dark: variant works automatically based on the .dark class or prefers-color-scheme media query.
/* app.css - Tailwind v4 CSS-first approach */
@import "tailwindcss";
@theme {
/* Define your theme tokens */
--color-background: oklch(1 0 0);
--color-foreground: oklch(0.145 0 0);
}
/* Dark mode via .dark class (used by next-themes) */
.dark {
--color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.985 0 0);
}
/* Or automatic detection via media query */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.985 0 0);
}
}No tailwind.config.js configuration needed - the dark: variant is enabled by default in v4 and responds to the .dark class on a parent element.
OKLCH Theming (2026 Standard)
Modern perceptually uniform color space for shadcn/ui themes.
Why OKLCH?
| Feature | OKLCH | HSL |
|---|---|---|
| Perceptual uniformity | Yes | No |
| Wide gamut support | Yes | Limited |
| Predictable lightness | Yes | No |
| Dark mode conversion | Easier | Manual |
Format: oklch(lightness chroma hue)
- Lightness: 0 (black) to 1 (white)
- Chroma: 0 (gray) to ~0.4 (most saturated)
- Hue: 0-360 degrees
Complete Theme Structure
:root {
/* Core semantic colors */
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
/* Card/Popover */
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
/* Primary brand color */
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
/* Secondary */
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
/* Muted/subdued */
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
/* Accent/highlight */
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
/* Destructive/danger */
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
/* Borders and inputs */
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
/* Radius scale */
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
}Tailwind Integration
@theme inline {
/* Map CSS variables to Tailwind utilities */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Radius scale */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}Chart Colors
Data visualization with distinct OKLCH hues:
:root {
--chart-1: oklch(0.646 0.222 41.116); /* Orange */
--chart-2: oklch(0.6 0.118 184.704); /* Teal */
--chart-3: oklch(0.398 0.07 227.392); /* Blue */
--chart-4: oklch(0.828 0.189 84.429); /* Yellow */
--chart-5: oklch(0.769 0.188 70.08); /* Amber */
}
.dark {
--chart-1: oklch(0.488 0.243 264.376); /* Indigo */
--chart-2: oklch(0.696 0.17 162.48); /* Cyan */
--chart-3: oklch(0.769 0.188 70.08); /* Amber */
--chart-4: oklch(0.627 0.265 303.9); /* Purple */
--chart-5: oklch(0.645 0.246 16.439); /* Red */
}Creating Custom Brand Colors
/* Blue brand example */
:root {
/* Primary blue - adjust lightness for variants */
--primary: oklch(0.546 0.245 262.881); /* Base blue */
--primary-foreground: oklch(0.97 0.014 254.604); /* Light text on blue */
/* Derived variants */
--primary-hover: oklch(0.496 0.245 262.881); /* Darker for hover */
--primary-muted: oklch(0.85 0.08 262.881); /* Muted background */
}
.dark {
--primary: oklch(0.707 0.165 254.624); /* Lighter in dark mode */
--primary-foreground: oklch(0.145 0 0); /* Dark text */
}Sidebar-Specific Colors
:root {
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}Converting from HSL
HSL: hsl(220, 70%, 50%)
↓
OKLCH: oklch(0.55 0.2 260)
Rough mapping:
- Lightness: HSL L% ÷ 100 ≈ OKLCH L
- Chroma: HSL S% × 0.003 ≈ OKLCH C (very rough)
- Hue: Similar but can shiftUse tools like oklch.com for accurate conversion.
Accessibility Considerations
- Minimum contrast ratio: 4.5:1 for normal text
- Large text (18px+): 3:1 minimum
- OKLCH makes it easier to maintain contrast by adjusting lightness predictably
/* Custom OKLCH Theme Template for shadcn/ui */
/* Copy and customize for your project */
@import "tailwindcss";
@import "tw-animate-css";
/* Custom dark mode variant */
@custom-variant dark (&:is(.dark *));
/* ============================================
LIGHT MODE (Default)
============================================ */
:root {
/* Background & Foreground */
--background: oklch(1 0 0);
--foreground: oklch(0.13 0.028 261.692);
/* Card */
--card: oklch(1 0 0);
--card-foreground: oklch(0.13 0.028 261.692);
/* Popover */
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.13 0.028 261.692);
/* Primary - Your brand color */
--primary: oklch(0.546 0.245 262.881);
--primary-foreground: oklch(0.97 0.014 254.604);
/* Secondary */
--secondary: oklch(0.967 0.003 264.542);
--secondary-foreground: oklch(0.21 0.034 264.665);
/* Muted */
--muted: oklch(0.967 0.003 264.542);
--muted-foreground: oklch(0.551 0.027 264.364);
/* Accent */
--accent: oklch(0.967 0.003 264.542);
--accent-foreground: oklch(0.21 0.034 264.665);
/* Destructive */
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
/* Success (custom) */
--success: oklch(0.6 0.2 145);
--success-foreground: oklch(0.985 0 0);
/* Warning (custom) */
--warning: oklch(0.75 0.18 85);
--warning-foreground: oklch(0.2 0 0);
/* Borders & Inputs */
--border: oklch(0.928 0.006 264.531);
--input: oklch(0.928 0.006 264.531);
--ring: oklch(0.546 0.245 262.881);
/* Radius Scale */
--radius: 0.625rem;
/* Chart Colors */
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
/* Sidebar */
--sidebar: oklch(0.985 0.002 247.839);
--sidebar-foreground: oklch(0.13 0.028 261.692);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.003 264.542);
--sidebar-accent-foreground: oklch(0.21 0.034 264.665);
--sidebar-border: oklch(0.928 0.006 264.531);
--sidebar-ring: oklch(0.546 0.245 262.881);
}
/* ============================================
DARK MODE
============================================ */
.dark {
/* Background & Foreground */
--background: oklch(0.13 0.028 261.692);
--foreground: oklch(0.985 0.002 247.839);
/* Card */
--card: oklch(0.18 0.025 261);
--card-foreground: oklch(0.985 0.002 247.839);
/* Popover */
--popover: oklch(0.18 0.025 261);
--popover-foreground: oklch(0.985 0.002 247.839);
/* Primary */
--primary: oklch(0.707 0.165 254.624);
--primary-foreground: oklch(0.13 0.028 261.692);
/* Secondary */
--secondary: oklch(0.278 0.033 256.848);
--secondary-foreground: oklch(0.985 0.002 247.839);
/* Muted */
--muted: oklch(0.278 0.033 256.848);
--muted-foreground: oklch(0.707 0.022 261.325);
/* Accent */
--accent: oklch(0.278 0.033 256.848);
--accent-foreground: oklch(0.985 0.002 247.839);
/* Destructive */
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
/* Success */
--success: oklch(0.65 0.18 145);
--success-foreground: oklch(0.13 0 0);
/* Warning */
--warning: oklch(0.8 0.16 85);
--warning-foreground: oklch(0.13 0 0);
/* Borders & Inputs */
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.707 0.165 254.624);
/* Chart Colors (adjusted for dark) */
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
/* Sidebar */
--sidebar: oklch(0.18 0.025 261);
--sidebar-foreground: oklch(0.985 0.002 247.839);
--sidebar-primary: oklch(0.707 0.165 254.624);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.278 0.033 256.848);
--sidebar-accent-foreground: oklch(0.985 0.002 247.839);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.707 0.165 254.624);
}
/* ============================================
TAILWIND THEME MAPPING
============================================ */
@theme inline {
/* Colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Chart colors */
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
/* Sidebar colors */
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* Radius */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
/* ============================================
BASE STYLES
============================================ */
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
// CVA Component Template
// Copy and customize for your project
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
// 1. Define variants with CVA
const componentVariants = cva(
// Base classes (always applied)
'inline-flex items-center justify-center rounded-md 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: {
// Visual style variant
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
},
// Size variant
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3 text-sm',
lg: 'h-11 rounded-md px-8 text-base',
icon: 'h-10 w-10',
},
},
// Compound variants for special combinations
compoundVariants: [
{
variant: 'outline',
size: 'lg',
className: 'border-2',
},
],
// Defaults when props not provided
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
// 2. Define props interface
export interface ComponentProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof componentVariants> {
asChild?: boolean
}
// 3. Create component with forwardRef
const Component = React.forwardRef<HTMLButtonElement, ComponentProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
// Support polymorphism via Slot
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(componentVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Component.displayName = 'Component'
// 4. Export component and variants
export { Component, componentVariants }
// Usage Examples:
/*
// Basic usage
<Component>Default Button</Component>
// With variants
<Component variant="destructive" size="lg">
Delete
</Component>
// With custom classes (overrides)
<Component className="w-full">
Full Width
</Component>
// As a link (polymorphic)
<Component asChild variant="link">
<a href="/about">About Us</a>
</Component>
// Access variant classes directly (for composition)
import { componentVariants } from './component'
const classes = componentVariants({ variant: 'outline', size: 'sm' })
*/
// Extended Button Template with Loading State
// Copy and customize for your project
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
// Base button variants (from shadcn)
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
// Extended props with loading state
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
loading?: boolean
loadingText?: string
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
asChild = false,
loading = false,
loadingText,
leftIcon,
rightIcon,
disabled,
children,
...props
},
ref
) => {
const Comp = asChild ? Slot : 'button'
const isDisabled = disabled || loading
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={isDisabled}
{...props}
>
{/* Loading spinner */}
{loading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
{/* Left icon (hidden when loading) */}
{!loading && leftIcon && (
<span className="mr-2" aria-hidden="true">
{leftIcon}
</span>
)}
{/* Content */}
{loading && loadingText ? loadingText : children}
{/* Right icon */}
{rightIcon && (
<span className="ml-2" aria-hidden="true">
{rightIcon}
</span>
)}
</Comp>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
// Usage Examples:
/*
import { Button } from '@/components/ui/button'
import { Send, Download, Trash } from 'lucide-react'
// Basic
<Button>Click me</Button>
// With loading
<Button loading>Saving...</Button>
<Button loading loadingText="Saving...">Save</Button>
// With icons
<Button leftIcon={<Send className="h-4 w-4" />}>
Send Message
</Button>
<Button rightIcon={<Download className="h-4 w-4" />}>
Download
</Button>
// Destructive with icon
<Button variant="destructive" leftIcon={<Trash className="h-4 w-4" />}>
Delete
</Button>
// As link
<Button asChild variant="link">
<a href="/docs">Documentation</a>
</Button>
// Combined states
<Button
variant="outline"
size="lg"
loading={isSubmitting}
loadingText="Submitting..."
leftIcon={<Send className="h-4 w-4" />}
>
Submit Form
</Button>
*/