shadcn/ui is a collection of reusable components built with Radix UI and Tailwind CSS. Components are copied into your project (not installed as a dependency), giving you full control.
Installation
Initialize in Project
Next.js or Vite
npx shadcn@latest init
Prompts:
-
Style: Default or New York
-
Base color: Slate, Gray, Zinc, Neutral, Stone
-
CSS variables: Yes (recommended)
-
tailwind.config location
-
components.json location
Add Components
Single component
npx shadcn@latest add button
Multiple components
npx shadcn@latest add button card dialog
All components
npx shadcn@latest add --all
With overwrite
npx shadcn@latest add button --overwrite
Project Structure
src/ ├── components/ │ └── ui/ # shadcn components live here │ ├── button.tsx │ ├── card.tsx │ └── dialog.tsx ├── lib/ │ └── utils.ts # cn() helper └── app/ └── globals.css # CSS variables
Core Utilities
cn() Helper
Merges Tailwind classes with conflict resolution:
// lib/utils.ts import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
Usage:
import { cn } from "@/lib/utils"
<div className={cn( "base-classes", conditional && "conditional-classes", className // Allow override from props )} />
Class Variance Authority (cva)
MUST use for component variants:
import { cva, type VariantProps } from "class-variance-authority"
const buttonVariants = cva( // Base classes "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2", { 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", 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", }, } )
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
const Button = ({ className, variant, size, ...props }: ButtonProps) => ( <button className={cn(buttonVariants({ variant, size, className }))} {...props} /> )
Component Patterns
Button
npx shadcn@latest add button
import { Button } from "@/components/ui/button"
// Variants <Button>Default</Button> <Button variant="secondary">Secondary</Button> <Button variant="destructive">Destructive</Button> <Button variant="outline">Outline</Button> <Button variant="ghost">Ghost</Button> <Button variant="link">Link</Button>
// Sizes <Button size="sm">Small</Button> <Button size="lg">Large</Button> <Button size="icon"><IconComponent /></Button>
// States <Button disabled>Disabled</Button> <Button asChild> <a href="/link">As Link</a> </Button>
// With loading <Button disabled> <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading </Button>
Dialog
npx shadcn@latest add dialog
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogClose, } from "@/components/ui/dialog"
<Dialog> <DialogTrigger asChild> <Button>Open Dialog</Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px]"> <DialogHeader> <DialogTitle>Edit Profile</DialogTitle> <DialogDescription> Make changes to your profile here. </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> {/* Content */} </div> <DialogFooter> <DialogClose asChild> <Button variant="outline">Cancel</Button> </DialogClose> <Button type="submit">Save</Button> </DialogFooter> </DialogContent> </Dialog>
Controlled Dialog:
const [open, setOpen] = useState(false)
<Dialog open={open} onOpenChange={setOpen}> <DialogContent> {/* ... */} <Button onClick={() => setOpen(false)}>Close</Button> </DialogContent> </Dialog>
Sheet (Slide-out Panel)
npx shadcn@latest add sheet
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from "@/components/ui/sheet"
<Sheet> <SheetTrigger asChild> <Button variant="outline">Open</Button> </SheetTrigger> <SheetContent side="right"> {/* left, right, top, bottom /} <SheetHeader> <SheetTitle>Menu</SheetTitle> <SheetDescription>Navigation</SheetDescription> </SheetHeader> {/ Content */} </SheetContent> </Sheet>
Dropdown Menu
npx shadcn@latest add dropdown-menu
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, } from "@/components/ui/dropdown-menu"
<DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline">Open</Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-56"> <DropdownMenuLabel>My Account</DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuItem> Profile <DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut> </DropdownMenuItem> <DropdownMenuItem disabled>Billing</DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuSub> <DropdownMenuSubTrigger>More</DropdownMenuSubTrigger> <DropdownMenuSubContent> <DropdownMenuItem>Sub Item</DropdownMenuItem> </DropdownMenuSubContent> </DropdownMenuSub> <DropdownMenuSeparator /> <DropdownMenuItem className="text-destructive"> Log out </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu>
Command (Command Palette)
npx shadcn@latest add command dialog
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, } from "@/components/ui/command"
// As dialog (⌘K pattern) const [open, setOpen] = useState(false)
useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault() setOpen((open) => !open) } } document.addEventListener("keydown", down) return () => document.removeEventListener("keydown", down) }, [])
<CommandDialog open={open} onOpenChange={setOpen}> <CommandInput placeholder="Type a command or search..." /> <CommandList> <CommandEmpty>No results found.</CommandEmpty> <CommandGroup heading="Suggestions"> <CommandItem onSelect={() => { /* action */ }}> <Calendar className="mr-2 h-4 w-4" /> <span>Calendar</span> </CommandItem> <CommandItem> <Search className="mr-2 h-4 w-4" /> <span>Search</span> <CommandShortcut>⌘K</CommandShortcut> </CommandItem> </CommandGroup> <CommandSeparator /> <CommandGroup heading="Settings"> <CommandItem>Profile</CommandItem> <CommandItem>Settings</CommandItem> </CommandGroup> </CommandList> </CommandDialog>
Form (with react-hook-form + zod)
npx shadcn@latest add form input button npm install zod react-hook-form @hookform/resolvers
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, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input"
const formSchema = z.object({ username: z.string().min(2, "Username must be at least 2 characters"), email: z.string().email("Invalid email address"), })
type FormValues = z.infer<typeof formSchema>
export function ProfileForm() { const form = useForm<FormValues>({ resolver: zodResolver(formSchema), defaultValues: { username: "", email: "", }, })
function onSubmit(values: FormValues) { console.log(values) }
return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <FormField control={form.control} name="username" render={({ field }) => ( <FormItem> <FormLabel>Username</FormLabel> <FormControl> <Input placeholder="johndoe" {...field} /> </FormControl> <FormDescription> Your public display name. </FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input type="email" placeholder="john@example.com" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <Button type="submit">Submit</Button> </form> </Form> ) }
Select
npx shadcn@latest add select
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"
<Select onValueChange={(value) => console.log(value)}> <SelectTrigger className="w-[180px]"> <SelectValue placeholder="Select a fruit" /> </SelectTrigger> <SelectContent> <SelectGroup> <SelectLabel>Fruits</SelectLabel> <SelectItem value="apple">Apple</SelectItem> <SelectItem value="banana">Banana</SelectItem> <SelectItem value="orange" disabled>Orange</SelectItem> </SelectGroup> </SelectContent> </Select>
Card
npx shadcn@latest add card
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"
<Card className="w-[350px]"> <CardHeader> <CardTitle>Create Project</CardTitle> <CardDescription>Deploy your project in one click.</CardDescription> </CardHeader> <CardContent> {/* Form fields */} </CardContent> <CardFooter className="flex justify-between"> <Button variant="outline">Cancel</Button> <Button>Deploy</Button> </CardFooter> </Card>
Table
npx shadcn@latest add table
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"
<Table> <TableCaption>A list of recent invoices.</TableCaption> <TableHeader> <TableRow> <TableHead className="w-[100px]">Invoice</TableHead> <TableHead>Status</TableHead> <TableHead className="text-right">Amount</TableHead> </TableRow> </TableHeader> <TableBody> {invoices.map((invoice) => ( <TableRow key={invoice.id}> <TableCell className="font-medium">{invoice.id}</TableCell> <TableCell>{invoice.status}</TableCell> <TableCell className="text-right">{invoice.amount}</TableCell> </TableRow> ))} </TableBody> </Table>
Data Table (with TanStack Table)
npx shadcn@latest add table npm install @tanstack/react-table
// columns.tsx import { ColumnDef } from "@tanstack/react-table"
export type Payment = { id: string amount: number status: "pending" | "processing" | "success" | "failed" email: string }
export const columns: ColumnDef<Payment>[] = [ { accessorKey: "status", header: "Status", }, { accessorKey: "email", header: "Email", }, { accessorKey: "amount", header: () => <div className="text-right">Amount</div>, cell: ({ row }) => { const amount = parseFloat(row.getValue("amount")) const formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(amount) return <div className="text-right font-medium">{formatted}</div> }, }, ]
// data-table.tsx import { flexRender, getCoreRowModel, useReactTable, } from "@tanstack/react-table"
export function DataTable<TData, TValue>({ columns, data, }: DataTableProps<TData, TValue>) { const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), })
return ( <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} </TableHead> ))} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( 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> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center"> No results. </TableCell> </TableRow> )} </TableBody> </Table> ) }
Toast (Sonner)
npx shadcn@latest add sonner
// app/layout.tsx import { Toaster } from "@/components/ui/sonner"
export default function RootLayout({ children }) { return ( <html> <body> {children} <Toaster /> </body> </html> ) }
// Usage anywhere import { toast } from "sonner"
toast("Event created") toast.success("Success!") toast.error("Error occurred") toast.warning("Warning") toast.info("Info message")
// With description toast("Event created", { description: "Sunday, December 03, 2023 at 9:00 AM", action: { label: "Undo", onClick: () => console.log("Undo"), }, })
// Promise toast toast.promise(saveData(), { loading: "Saving...", success: "Data saved!", error: "Error saving data", })
Tabs
npx shadcn@latest add tabs
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
<Tabs defaultValue="account" className="w-[400px]"> <TabsList className="grid w-full grid-cols-2"> <TabsTrigger value="account">Account</TabsTrigger> <TabsTrigger value="password">Password</TabsTrigger> </TabsList> <TabsContent value="account"> <Card> <CardHeader> <CardTitle>Account</CardTitle> </CardHeader> <CardContent>{/* ... /}</CardContent> </Card> </TabsContent> <TabsContent value="password"> <Card> <CardHeader> <CardTitle>Password</CardTitle> </CardHeader> <CardContent>{/ ... */}</CardContent> </Card> </TabsContent> </Tabs>
Theming
CSS Variables
shadcn uses CSS variables for theming in globals.css :
@layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; }
.dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; } }
Dark Mode (next-themes)
npm install next-themes
// app/providers.tsx "use client"
import { ThemeProvider } from "next-themes"
export function Providers({ children }: { children: React.ReactNode }) { return ( <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > {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> ) }
Theme Toggle Component:
"use client"
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 ThemeToggle() { 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> ) }
Custom Colors
Add custom colors to your theme:
:root { --success: 142 76% 36%; --success-foreground: 0 0% 100%; --warning: 38 92% 50%; --warning-foreground: 0 0% 100%; }
Use in components:
const alertVariants = cva("...", { variants: { variant: { success: "bg-success text-success-foreground", warning: "bg-warning text-warning-foreground", }, }, })
Common Patterns
Loading Button
import { Loader2 } from "lucide-react"
interface LoadingButtonProps extends ButtonProps { loading?: boolean }
export function LoadingButton({ loading, children, disabled, ...props }: LoadingButtonProps) { return ( <Button disabled={loading || disabled} {...props}> {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {children} </Button> ) }
Confirmation Dialog
interface ConfirmDialogProps { open: boolean onOpenChange: (open: boolean) => void onConfirm: () => void title: string description: string confirmText?: string cancelText?: string destructive?: boolean }
export function ConfirmDialog({ open, onOpenChange, onConfirm, title, description, confirmText = "Confirm", cancelText = "Cancel", destructive = false, }: ConfirmDialogProps) { return ( <AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>{title}</AlertDialogTitle> <AlertDialogDescription>{description}</AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>{cancelText}</AlertDialogCancel> <AlertDialogAction onClick={onConfirm} className={cn(destructive && "bg-destructive text-destructive-foreground hover:bg-destructive/90")} > {confirmText} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ) }
Responsive Drawer/Dialog
Mobile drawer, desktop dialog:
npx shadcn@latest add dialog drawer
import { useMediaQuery } from "@/hooks/use-media-query"
export function ResponsiveDialog({ children, ...props }) { const isDesktop = useMediaQuery("(min-width: 768px)")
if (isDesktop) { return <Dialog {...props}>{children}</Dialog> }
return <Drawer {...props}>{children}</Drawer> }
Combobox (Searchable Select)
npx shadcn@latest add command popover
import { Check, ChevronsUpDown } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"
const frameworks = [ { value: "next.js", label: "Next.js" }, { value: "sveltekit", label: "SvelteKit" }, { value: "nuxt.js", label: "Nuxt.js" }, ]
export function Combobox() { const [open, setOpen] = useState(false) const [value, setValue] = useState("")
return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button variant="outline" role="combobox" aria-expanded={open} className="w-[200px] justify-between" > {value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> <PopoverContent className="w-[200px] p-0"> <Command> <CommandInput placeholder="Search framework..." /> <CommandList> <CommandEmpty>No framework found.</CommandEmpty> <CommandGroup> {frameworks.map((framework) => ( <CommandItem key={framework.value} value={framework.value} onSelect={(currentValue) => { setValue(currentValue === value ? "" : currentValue) setOpen(false) }} > <Check className={cn( "mr-2 h-4 w-4", value === framework.value ? "opacity-100" : "opacity-0" )} /> {framework.label} </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> ) }
Available Components
Layout
- Accordion, AspectRatio, Card, Collapsible, ResizablePanels, ScrollArea, Separator, Tabs
Forms
- Button, Checkbox, Form, Input, InputOTP, Label, RadioGroup, Select, Slider, Switch, Textarea, Toggle, ToggleGroup
Data Display
- Avatar, Badge, Calendar, DataTable, Table
Feedback
- Alert, AlertDialog, Progress, Skeleton, Sonner (Toast), Tooltip
Navigation
- Breadcrumb, Command, ContextMenu, DropdownMenu, Menubar, NavigationMenu, Pagination
Overlay
- Dialog, Drawer, HoverCard, Popover, Sheet
Tips
-
MUST use asChild when wrapping custom elements in triggers
-
MUST use cn() for class merging - never concatenate classes manually
-
Controlled vs Uncontrolled - Use defaultValue for uncontrolled, value
- onValueChange for controlled
-
Accessibility - Radix handles a11y, but SHOULD always add sr-only labels for icon buttons
-
Customize at source - Edit files in components/ui/ directly for project-wide changes