shadcn-ui
shadcn/ui Expert
Comprehensive guide to building UIs with shadcn/ui -- the copy-paste component library built on Radix UI primitives and Tailwind CSS. Components are not installed as a dependency; they are copied into your project for full ownership and customization.
When to Apply
Use this skill when:
- Adding shadcn/ui components to a React or Next.js project
- Customizing component styles, variants, or behavior
- Setting up Tailwind CSS v4 theming with CSS variables
- Implementing dark mode with shadcn/ui
- Building accessible forms, dialogs, or data tables
- Choosing between shadcn/ui components and custom implementations
Core Concepts
shadcn/ui is NOT a Component Library
shadcn/ui is a collection of reusable components that you copy into your project. Key differences from traditional libraries:
- No npm package dependency -- components live in your codebase
- Full ownership -- modify any component freely
- Radix UI primitives -- accessible, unstyled headless components under the hood
- Tailwind CSS -- all styling via utility classes and CSS variables
- CLI-driven --
npx shadcn@latest add buttoncopies component code
Architecture
Your Project
components/
ui/ <- shadcn/ui components live here
button.tsx
dialog.tsx
input.tsx
...
lib/
utils.ts <- cn() utility (clsx + tailwind-merge)
Setup
Next.js App Router (Recommended)
# Initialize shadcn/ui in existing Next.js project
npx shadcn@latest init
# This creates:
# - components.json (configuration)
# - lib/utils.ts (cn utility)
# - Tailwind CSS variable theme in globals.css
components.json Configuration:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
Vite + React
# Initialize
npx shadcn@latest init
# Vite requires path aliases in vite.config.ts:
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
Next.js Pages Router
Same as App Router but set rsc: false in components.json since Pages Router does not support React Server Components.
Adding Components
# Add a single component
npx shadcn@latest add button
# Add multiple components
npx shadcn@latest add button card input label
# Add all components
npx shadcn@latest add --all
# View available components
npx shadcn@latest add --list
The cn() Utility
Every shadcn/ui component uses cn() for conditional class merging:
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
cn() combines clsx (conditional classes) with tailwind-merge (resolves Tailwind conflicts):
<Button
className={cn(
'bg-primary text-white',
isDisabled && 'opacity-50 cursor-not-allowed',
size === 'lg' && 'px-8 py-4 text-lg'
)}
>
Submit
</Button>
Theming with CSS Variables
Tailwind CSS v4 Theme Setup
shadcn/ui uses CSS custom properties for theming, enabling runtime theme switching:
/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
Dark Mode Implementation
Use next-themes for Next.js dark mode:
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
);
}
// 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:
'use client';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import { Moon, Sun } from 'lucide-react';
export function ThemeToggle() {
const { setTheme, theme } = 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>
);
}
Common Component Patterns
Forms with React Hook Form + Zod
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
const formSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export function LoginForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { email: '', password: '' },
});
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="name@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Sign In</Button>
</form>
</Form>
);
}
Data Tables with TanStack Table
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(header => (
<TableHead key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map(row => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
Responsive Dialog / Drawer Pattern
Use Dialog on desktop and Drawer on mobile:
'use client';
import { useMediaQuery } from '@/hooks/use-media-query';
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog';
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer';
import { Button } from '@/components/ui/button';
export function ResponsiveModal({ children }: { children: React.ReactNode }) {
const isDesktop = useMediaQuery('(min-width: 768px)');
if (isDesktop) {
return (
<Dialog>
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
<DialogContent>{children}</DialogContent>
</Dialog>
);
}
return (
<Drawer>
<DrawerTrigger asChild>
<Button>Open</Button>
</DrawerTrigger>
<DrawerContent>{children}</DrawerContent>
</Drawer>
);
}
Accessibility Patterns
shadcn/ui components are built on Radix UI, which provides:
- Full keyboard navigation (Tab, Arrow keys, Enter, Escape)
- ARIA attributes (roles, states, properties)
- Focus management (trapping, restoration)
- Screen reader announcements
Key Accessibility Features by Component
| Component | Keyboard | ARIA | Focus Trap |
|---|---|---|---|
| Button | Enter/Space to activate | role="button" | No |
| Dialog | Escape to close | role="dialog", aria-modal | Yes |
| Dropdown Menu | Arrow keys to navigate | role="menu", role="menuitem" | Yes |
| Select | Arrow keys, type-ahead | role="listbox", role="option" | Yes |
| Tabs | Arrow keys between tabs | role="tablist", role="tab" | No |
| Toast | Auto-announce | role="status", aria-live | No |
| Tooltip | Focus/hover to show | role="tooltip" | No |
Custom Accessibility Enhancements
// Always provide labels for interactive elements
<Button aria-label="Close dialog">
<X className="h-4 w-4" />
</Button>
// Use sr-only for visual-only content
<span className="sr-only">Loading...</span>
// Announce dynamic content
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
Anti-Patterns
- Do NOT install shadcn/ui as an npm package -- use the CLI to copy components
- Do NOT modify Radix primitives directly -- extend via the shadcn wrapper component
- Do NOT use hardcoded colors -- always use CSS variable theme tokens
- Do NOT skip the
cn()utility -- it prevents Tailwind class conflicts - Do NOT forget
suppressHydrationWarningon<html>when using next-themes - Do NOT nest interactive elements (button inside button, link inside button)
Iron Laws
- NEVER install shadcn/ui as a package dependency — components must be copied into the project for full ownership
- ALWAYS use the
cn()utility for conditional class names to prevent Tailwind class conflicts - NEVER hardcode colors — always use CSS variable theme tokens for theming consistency
- ALWAYS use Radix UI primitives through the shadcn/ui abstraction, not directly
- NEVER nest interactive elements (button inside button, link inside button) — violates accessibility standards
Anti-Patterns
| Anti-Pattern | Why It Fails | Correct Approach |
|---|---|---|
| Installing as a package | Component source is locked; no customization possible | Use npx shadcn@latest add to copy components into your project |
| Hardcoding color values | Theme switching breaks; dark mode fails | Use CSS variable tokens (bg-background, text-foreground, etc.) |
Skipping cn() utility |
Tailwind class conflicts produce unpredictable styles | Always merge classes with cn() from @/lib/utils |
| Direct Radix UI primitive use | Missing shadcn styling and accessibility wiring | Use shadcn components that wrap Radix primitives with correct classes |
Missing suppressHydrationWarning |
Hydration mismatch errors with next-themes dark mode | Add suppressHydrationWarning to <html> when using next-themes |
Memory Protocol (MANDATORY)
Before starting:
Read .claude/context/memory/learnings.md
After completing:
- New pattern ->
.claude/context/memory/learnings.md - Issue found ->
.claude/context/memory/issues.md - Decision made ->
.claude/context/memory/decisions.md
ASSUME INTERRUPTION: If it's not in memory, it didn't happen.