shadcn/ui Complete Guide | Radix UI, Tailwind, Copy-Paste Components
이 글의 핵심
shadcn/ui is a collection of reusable components built with Radix UI and Tailwind CSS. Unlike traditional libraries, you copy components directly into your project for full control and customization.
Introduction
shadcn/ui is not a traditional component library. Instead of installing an npm package, you copy components directly into your project. This approach gives you complete control over the code while providing beautifully designed, accessible components.
Why shadcn/ui?
Traditional Libraries (Material-UI, Ant Design):
- Large bundle size (import everything)
- Difficult to customize deeply
- Fighting the framework’s opinions
- Dependencies you can’t control
shadcn/ui Approach:
- Copy only what you need
- Own and modify the code
- Built on Radix UI (accessibility)
- Styled with Tailwind (flexibility)
1. Installation & Setup
Prerequisites
# Requires Node.js 18+
node --version
# Create Next.js project (recommended)
npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
Initialize shadcn/ui
npx shadcn-ui@latest init
Configuration prompts:
✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Slate
✔ Would you like to use CSS variables for colors? › yes
Generated Files
my-app/
├── components/
│ └── ui/ # Components go here
├── lib/
│ └── utils.ts # Utility functions (cn helper)
├── tailwind.config.ts
└── components.json # shadcn/ui config
components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
2. Adding Components
Button Component
npx shadcn-ui@latest add button
This copies the Button component to components/ui/button.tsx.
Usage:
// app/page.tsx
import { Button } from '@/components/ui/button';
export default function Home() {
return (
<div className="p-8 space-x-4">
<Button>Default</Button>
<Button variant="destructive">Delete</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
<Button size="sm">Small</Button>
<Button size="lg">Large</Button>
<Button size="icon">🚀</Button>
</div>
);
}
Card Component
npx shadcn-ui@latest add card
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
export default function ProductCard() {
return (
<Card className="w-[350px]">
<CardHeader>
<CardTitle>Product Name</CardTitle>
<CardDescription>Product description goes here</CardDescription>
</CardHeader>
<CardContent>
<p>Detailed product information...</p>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Cancel</Button>
<Button>Buy Now</Button>
</CardFooter>
</Card>
);
}
Form Components
npx shadcn-ui@latest add input label
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export default function LoginForm() {
return (
<form className="space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="you@example.com" />
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" />
</div>
<Button type="submit" className="w-full">
Sign In
</Button>
</form>
);
}
3. Form Integration with React Hook Form
npx shadcn-ui@latest add form
npm install react-hook-form @hookform/resolvers 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,
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'),
});
export default function ProfileForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: '',
email: '',
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
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>
This is 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">Update profile</Button>
</form>
</Form>
);
}
4. Dialog & Modal
npx shadcn-ui@latest add dialog
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export default function UserDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button>Edit Profile</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Make changes to your profile here. Click save when you're done.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input id="name" defaultValue="Pedro Duarte" className="col-span-3" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="username" className="text-right">
Username
</Label>
<Input id="username" defaultValue="@peduarte" className="col-span-3" />
</div>
</div>
<DialogFooter>
<Button type="submit">Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
5. Data Tables
npx shadcn-ui@latest add table
npm install @tanstack/react-table
'use client';
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
const invoices = [
{ id: 'INV001', status: 'Paid', amount: '$250.00' },
{ id: 'INV002', status: 'Pending', amount: '$150.00' },
{ id: 'INV003', status: 'Unpaid', amount: '$350.00' },
];
export default function InvoiceTable() {
return (
<Table>
<TableCaption>A list of your 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>
);
}
6. Toast Notifications
npx shadcn-ui@latest add toast
'use client';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
export default function ToastDemo() {
const { toast } = useToast();
return (
<Button
onClick={() => {
toast({
title: 'Scheduled: Catch up',
description: 'Friday, February 10, 2023 at 5:57 PM',
});
}}
>
Show Toast
</Button>
);
}
Toaster Component:
// app/layout.tsx
import { Toaster } from '@/components/ui/toaster';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Toaster />
</body>
</html>
);
}
7. Customization
Modify Component Styles
Since you own the code, you can modify components directly:
// components/ui/button.tsx
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
// Add your custom variant
custom: 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:opacity-90',
},
},
}
);
Theme Colors
Edit globals.css:
@layer base {
:root {
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
/* Add custom colors */
--custom: 280 100% 70%;
}
.dark {
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
}
}
8. Dark Mode
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>
{children}
</ThemeProvider>
);
}
Theme Toggle:
'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>
);
}
9. Server Components
shadcn/ui works with Next.js Server Components:
// app/users/page.tsx (Server Component)
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
async function getUsers() {
const res = await fetch('https://api.example.com/users');
return res.json();
}
export default async function UsersPage() {
const users = await getUsers();
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{users.map((user: any) => (
<Card key={user.id}>
<CardHeader>
<CardTitle>{user.name}</CardTitle>
</CardHeader>
<CardContent>
<p>{user.email}</p>
</CardContent>
</Card>
))}
</div>
);
}
10. Best Practices
1. Use the cn Utility
import { cn } from '@/lib/utils';
<Button className={cn('w-full', isLoading && 'opacity-50')} />
2. Create Composed Components
// components/search-input.tsx
import { Input } from '@/components/ui/input';
import { Search } from 'lucide-react';
export function SearchInput() {
return (
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search..." className="pl-8" />
</div>
);
}
3. Organize Components
components/
├── ui/ # shadcn/ui components
├── forms/ # Form-specific components
├── layouts/ # Layout components
└── shared/ # Shared custom components
4. Type Safety
import { type ButtonProps } from '@/components/ui/button';
interface CustomButtonProps extends ButtonProps {
icon?: React.ReactNode;
}
export function CustomButton({ icon, children, ...props }: CustomButtonProps) {
return (
<Button {...props}>
{icon && <span className="mr-2">{icon}</span>}
{children}
</Button>
);
}
11. Common Patterns
Loading States
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
export function LoadingButton() {
const [isLoading, setIsLoading] = useState(false);
return (
<Button disabled={isLoading} onClick={() => setIsLoading(true)}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isLoading ? 'Loading...' : 'Submit'}
</Button>
);
}
Confirmation Dialog
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
export function DeleteConfirmation() {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => console.log('Deleted')}>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
Summary
shadcn/ui revolutionizes how we build UIs in React:
- Copy-paste components you own and control
- Built on Radix UI for perfect accessibility
- Styled with Tailwind for easy customization
- TypeScript first with excellent type safety
- Works with Server Components in Next.js
Key Takeaways:
- You own the code - modify freely
- Only copy what you need - smaller bundles
- Built on solid foundations (Radix + Tailwind)
- Perfect for design systems
- Excellent developer experience
Next Steps:
- Explore Tailwind CSS for advanced styling
- Learn React Hook Form for forms
- Check Next.js 15 for full-stack apps
Resources: