shadcn-ui
shadcn/ui
When to Use
shadcn/ui is the default component and styling approach for all frontend projects. Always prefer shadcn/ui components (<Button>, <Input>, <Card>, etc.) over raw HTML elements or hand-styled Tailwind.
Use this skill for:
- Component installation, composition, and customization
- Theming with CSS variables (OKLCH color space)
- Form integration with react-hook-form + zod
- Accessible UI patterns (via Radix UI primitives)
Only skip shadcn/ui when a project explicitly uses a different component system (e.g., MUI, Chakra).
Installation and Setup
Initialize in a Next.js Project
npx shadcn@latest init
This creates:
components.json— configuration file (style is always"new-york", the only supported style)lib/utils.ts— thecn()utility (clsx + tailwind-merge)components/ui/— raw shadcn/ui components (avoid heavy modifications)- Recommended additional directories:
components/primitives/(lightly customized wrappers),components/blocks/(product-level compositions)
Key components.json fields:
tailwind.config: Leave blank for Tailwind v4 projectstailwind.cssVariables:true(default, uses OKLCH color space)registries: Optional array for custom component registries
CLI Commands
npx shadcn@latest init # Initialize project
npx shadcn@latest add button # Add component(s)
npx shadcn@latest add --all # Add all components
npx shadcn@latest list # List available components
npx shadcn@latest diff button # Show changes vs registry
npx shadcn@latest build # Build registry for publishing
npx shadcn@latest migrate radix # Migrate to unified radix-ui package
Use --rtl flag with init for right-to-left layout support.
Components are copied into your codebase (not installed as dependencies). You own the code and can modify it.
Ecosystem Notes
- Tailwind v4: CSS-first configuration via
@themedirective inglobals.css. Notailwind.config.jsneeded. Tailwind v3 projects are still supported. - tw-animate-css: Replaces deprecated
tailwindcss-animate. Install withnpm install tw-animate-cssand import inglobals.css:@import "tw-animate-css"; - Unified Radix package: The
radix-uipackage replaces individual@radix-ui/react-*packages. Migrate withnpx shadcn@latest migrate radix. - React 19:
forwardRefis no longer needed. Components acceptrefas a regular prop. Both patterns work in existing codebases. - "new-york" only: The old "default" style is deprecated. All new projects use "new-york".
The cn() Utility
Always use cn() for conditional/merged class names:
import { cn } from "@/lib/utils";
<div
className={cn(
"flex items-center gap-2",
isActive && "bg-primary text-primary-foreground",
className, // always forward className prop
)}
/>;
Component Usage Patterns
Dialog
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
function DeleteDialog({ onConfirm }: { onConfirm: () => void }) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
onConfirm();
setOpen(false);
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Sheet (Side Panel)
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Open Settings</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[400px]">
<SheetHeader>
<SheetTitle>Settings</SheetTitle>
<SheetDescription>Configure your preferences.</SheetDescription>
</SheetHeader>
{/* Content here */}
</SheetContent>
</Sheet>;
Command (Command Palette / Combobox)
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
function CommandMenu() {
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);
}, []);
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Actions">
<CommandItem onSelect={() => {}}>
<span>New Document</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);
}
DataTable (with TanStack Table)
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
// Add getSortedRowModel, getFilteredRowModel, getPaginationRowModel as needed
function DataTable<TData, TValue>({
columns,
data,
}: {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}) {
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.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>
);
}
Other Notable Components
These are available via npx shadcn@latest add <name> (see official docs for usage):
- Sidebar — app-level navigation with collapsible groups
- Chart — chart components built on Recharts
- Drawer — mobile-friendly bottom sheet (Vaul-based)
- Carousel — content carousel (Embla-based)
- Resizable — resizable panel layouts
- Field, Input Group, Button Group — form layout helpers
- Spinner — loading spinner component
- Kbd — keyboard shortcut display
- Empty — empty state component
Composition Patterns
Controlled vs Uncontrolled
Most shadcn/ui components support both patterns:
// Uncontrolled — component manages its own state
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>...</DialogContent>
</Dialog>
// Controlled — you manage the state
const [open, setOpen] = useState(false)
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>...</DialogContent>
</Dialog>
Use controlled when you need to:
- Close on form submit
- Open programmatically (e.g., after an action)
- Prevent closing under certain conditions
The asChild Pattern
Use asChild to render a different element while keeping the trigger behavior:
// Renders a <button> wrapping an <a> — BAD
<DialogTrigger>
<Link href="/settings">Settings</Link>
</DialogTrigger>
// Renders just the <a> with trigger behavior — GOOD
<DialogTrigger asChild>
<Link href="/settings">Settings</Link>
</DialogTrigger>
Forwarding className
Always forward className in custom components built on shadcn/ui:
interface CustomCardProps extends React.ComponentProps<typeof Card> {
title: string;
}
function CustomCard({ title, className, ...props }: CustomCardProps) {
return (
<Card className={cn("p-6", className)} {...props}>
<CardTitle>{title}</CardTitle>
</Card>
);
}
With React 19, ref is a regular prop — no forwardRef wrapper needed. React.ComponentProps<typeof Card> already includes ref in React 19.
Styling and Theming
CSS Variables
shadcn/ui uses CSS variables for theming, defined in globals.css. New projects use OKLCH color space. In Tailwind v4 projects, theme tokens are registered with @theme inline instead of tailwind.config.js. Existing HSL-based projects continue to work.
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0.002 247.858);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0.002 247.858);
/* ... dark mode values */
}
Extending with Tailwind
Use Tailwind utilities to customize components. The cn() function handles merge conflicts:
<Button className="w-full justify-start text-left font-normal">
Select a date
</Button>
Dark Mode
shadcn/ui supports dark mode via the dark class on <html>. Use next-themes:
import { ThemeProvider } from "next-themes";
// In layout.tsx
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>;
Toggle:
import { useTheme } from "next-themes";
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 dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 dark:rotate-0 dark:scale-100" />
</Button>
);
}
Form Integration (react-hook-form + zod)
Schema Definition
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
role: z.enum(["admin", "user", "viewer"]),
notifications: z.boolean().default(false),
});
type FormValues = z.infer<typeof formSchema>;
Form Component
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
function UserForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
email: "",
role: "user",
notifications: false,
},
});
function onSubmit(values: FormValues) {
// values is fully typed and validated
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormDescription>Your display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save</Button>
</form>
</Form>
);
}
Accessibility
Built-in Defaults
shadcn/ui components (via Radix UI) include:
- Keyboard navigation (Tab, Enter, Escape, Arrow keys)
- Focus management (focus trap in dialogs, return focus on close)
- ARIA attributes (role, aria-expanded, aria-controls, etc.)
- Screen reader announcements
Required Additions
You must still provide:
// 1. Always include DialogDescription (or visually hide it)
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Make changes to your profile here.</DialogDescription>
</DialogHeader>
// If no visible description, use VisuallyHidden:
<DialogDescription className="sr-only">
Dialog for editing profile settings
</DialogDescription>
// 2. Label form inputs
<FormField
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel> {/* Required for accessibility */}
<FormControl>
<Input {...field} />
</FormControl>
</FormItem>
)}
/>
// 3. Add aria-label for icon-only buttons
<Button variant="ghost" size="icon" aria-label="Close menu">
<X className="h-4 w-4" />
</Button>
// 4. data-testid for testing
<Button data-testid="submit-form">Submit</Button>
Common UI Patterns
Loading States
import { Loader2 } from "lucide-react";
<Button disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isLoading ? "Saving..." : "Save Changes"}
</Button>;
// Skeleton for content loading
import { Skeleton } from "@/components/ui/skeleton";
<Card>
<CardHeader>
<Skeleton className="h-6 w-[200px]" />
<Skeleton className="h-4 w-[300px]" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>;
Empty States
Use the <Empty> component from shadcn/ui, or build a simple one:
<div className="flex flex-col items-center justify-center py-12 text-center">
<InboxIcon className="h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold">No results</h3>
<p className="mt-2 text-sm text-muted-foreground">
Get started by creating your first item.
</p>
<Button className="mt-4">Create New</Button>
</div>
Error Display
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
function ErrorAlert({ message }: { message: string }) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{message}</AlertDescription>
</Alert>
);
}
Toast Notifications (Sonner)
The old useToast hook is deprecated. Use sonner instead:
npx shadcn@latest add sonner
Add <Toaster /> once in your root layout:
import { Toaster } from "@/components/ui/sonner";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
{children}
<Toaster />
</body>
</html>
);
}
import { toast } from "sonner";
function SaveButton() {
async function handleSave() {
try {
await save();
toast.success("Your changes have been saved.");
} catch {
toast.error("Failed to save changes.");
}
}
return <Button onClick={handleSave}>Save</Button>;
}
Anti-Patterns
1. Raw HTML Instead of Components
// BAD
<button className="bg-blue-500 text-white px-4 py-2 rounded">Click</button>
// GOOD
<Button>Click</Button>
2. Not Using asChild for Custom Triggers
// BAD — nested interactive elements
<DialogTrigger><button>Open</button></DialogTrigger>
// GOOD
<DialogTrigger asChild><Button>Open</Button></DialogTrigger>
3. Hardcoding Colors Instead of CSS Variables
// BAD
<div className="bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white">
// GOOD — uses theme variables, dark mode automatic
<div className="bg-background text-foreground">
Never hardcode oklch(...) values either — always use semantic class names like bg-primary, text-muted-foreground.
4. Missing Form Validation Feedback
// BAD — no error message shown
<FormField
name="email"
render={({ field }) => (
<FormItem>
<Input {...field} />
</FormItem>
)}
/>
// GOOD — shows validation errors
<FormField
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage /> {/* Shows zod validation errors */}
</FormItem>
)}
/>
5. Not Forwarding Props
// BAD — className, onClick, etc. are lost
function MyButton({ label }: { label: string }) {
return <Button>{label}</Button>;
}
// GOOD — all button props forwarded
function MyButton({
label,
...props
}: { label: string } & React.ComponentProps<typeof Button>) {
return <Button {...props}>{label}</Button>;
}
6. Using Deprecated useToast
// DEPRECATED — do not use
import { useToast } from "@/hooks/use-toast";
const { toast } = useToast();
// CORRECT — use sonner
import { toast } from "sonner";
toast.success("Saved");