shadcn/ui 완벽 가이드 | Radix UI·Tailwind·복사 가능한 컴포넌트·커스터마이징
이 글의 핵심
shadcn/ui로 아름다운 UI를 빠르게 구축하는 완벽 가이드입니다. 설치부터 컴포넌트 추가, 커스터마이징, 테마, 다크모드까지 실전 예제로 정리했습니다.
실무 경험 공유: 관리자 대시보드를 shadcn/ui로 구축하면서, UI 개발 시간을 70% 단축하고 일관된 디자인 시스템을 확립한 경험을 공유합니다.
들어가며: “UI 라이브러리가 불편해요”
실무 문제 시나리오
시나리오 1: 커스터마이징이 어려워요
Material-UI는 스타일 오버라이드가 복잡합니다. shadcn/ui는 코드를 직접 수정합니다.
시나리오 2: 번들이 너무 커요
전체 라이브러리를 설치해야 합니다. shadcn/ui는 필요한 컴포넌트만 복사합니다.
시나리오 3: 디자인이 획일적이에요
모든 사이트가 비슷해 보입니다. shadcn/ui는 완전히 커스터마이징 가능합니다.
1. shadcn/ui란?
핵심 특징
shadcn/ui는 복사 가능한 컴포넌트 컬렉션입니다.
주요 장점:
- 복사 가능: npm 패키지가 아닌 코드 복사
- 완전한 제어: 모든 코드를 직접 수정 가능
- Radix UI 기반: 접근성 완벽 지원
- Tailwind CSS: 유틸리티 우선 스타일링
- TypeScript: 완벽한 타입 지원
2. 설치 및 설정
Next.js 프로젝트 생성
npx create-next-app@latest my-app --typescript --tailwind --eslint
cd my-app
shadcn/ui 초기화
npx shadcn-ui@latest init
설정 옵션
✔ 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
생성된 파일
my-app/
├── components/
│ └── ui/ # 컴포넌트가 여기에 추가됨
├── lib/
│ └── utils.ts # 유틸리티 함수
├── tailwind.config.ts
└── components.json # shadcn/ui 설정
3. 컴포넌트 추가
단일 컴포넌트
npx shadcn-ui@latest add button
여러 컴포넌트
npx shadcn-ui@latest add button card input label
모든 컴포넌트
npx shadcn-ui@latest add --all
4. 기본 컴포넌트 사용
Button
import { Button } from '@/components/ui/button';
export default function Page() {
return (
<div className="space-x-2">
<Button>Default</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
</div>
);
}
Input
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export default function Page() {
return (
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="Email" />
</div>
);
}
Card
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
export default function Page() {
return (
<Card>
<CardHeader>
<CardTitle>Card Title</CardTitle>
<CardDescription>Card Description</CardDescription>
</CardHeader>
<CardContent>
<p>Card Content</p>
</CardContent>
<CardFooter>
<Button>Footer button</Button>
</CardFooter>
</Card>
);
}
5. Form 구축
설치
npm install react-hook-form @hookform/resolvers zod
npx shadcn-ui@latest add form
Form 예제
'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, {
message: 'Username must be at least 2 characters.',
}),
email: z.string().email({
message: 'Please enter a valid email address.',
}),
});
export 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 placeholder="[email protected]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}
6. 다크모드
설치
npm install next-themes
Provider 설정
// 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,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
테마 토글
'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 === 'light' ? 'dark' : 'light')}
>
<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>
);
}
7. 커스터마이징
색상 변경
/* app/globals.css */
@layer base {
:root {
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
}
.dark {
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
}
}
컴포넌트 수정
// 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',
// 새로운 variant 추가
success: 'bg-green-500 text-white hover:bg-green-600',
},
},
}
);
8. 실전 예제: 대시보드
// app/dashboard/page.tsx
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
export default function Dashboard() {
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Dashboard</h1>
<Button>Add New</Button>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Revenue
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$45,231.89</div>
<p className="text-xs text-muted-foreground">
+20.1% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Subscriptions
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+2350</div>
<p className="text-xs text-muted-foreground">
+180.1% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sales</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+12,234</div>
<p className="text-xs text-muted-foreground">
+19% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Active Now
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+573</div>
<p className="text-xs text-muted-foreground">
+201 since last hour
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Recent Sales</CardTitle>
<CardDescription>
You made 265 sales this month.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead>Email</TableHead>
<TableHead>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>John Doe</TableCell>
<TableCell>[email protected]</TableCell>
<TableCell>$250.00</TableCell>
</TableRow>
<TableRow>
<TableCell>Jane Smith</TableCell>
<TableCell>[email protected]</TableCell>
<TableCell>$150.00</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
9. 고급 컴포넌트
Dialog
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
export function DialogDemo() {
return (
<Dialog>
<DialogTrigger asChild>
<Button>Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
Dropdown Menu
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
export function DropdownMenuDemo() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">Open</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
정리 및 체크리스트
핵심 요약
- shadcn/ui: 복사 가능한 컴포넌트 컬렉션
- 완전한 제어: 모든 코드를 직접 수정 가능
- Radix UI: 접근성 완벽 지원
- Tailwind CSS: 유틸리티 우선 스타일링
- 다크모드: 내장 지원
구현 체크리스트
- shadcn/ui 초기화
- 필요한 컴포넌트 추가
- 테마 설정
- 다크모드 구현
- Form 구축
- 커스터마이징
같이 보면 좋은 글
- Tailwind CSS 완벽 가이드
- React 18 심화 가이드
- Next.js 15 완벽 가이드
이 글에서 다루는 키워드
shadcn/ui, Radix UI, Tailwind CSS, React, UI Components, Design System
자주 묻는 질문 (FAQ)
Q. shadcn/ui vs Material-UI, 어떤 게 나은가요?
A. shadcn/ui는 더 가볍고 커스터마이징이 쉽습니다. Material-UI는 더 많은 컴포넌트를 제공하지만 무겁습니다.
Q. 왜 npm 패키지가 아닌가요?
A. 코드를 직접 소유하고 수정할 수 있게 하기 위함입니다. 의존성 지옥을 피할 수 있습니다.
Q. 접근성은 어떤가요?
A. Radix UI 기반이므로 접근성이 완벽합니다. ARIA 속성, 키보드 네비게이션 등을 모두 지원합니다.
Q. 프로덕션에서 사용해도 되나요?
A. 네, 많은 기업에서 프로덕션 환경에서 사용하고 있습니다. Vercel도 사용합니다.