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 button copies 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 suppressHydrationWarning on <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.
References
-
shadcn/ui Documentation
-
Radix UI Primitives
-
Tailwind CSS v4
-
next-themes