shadcn/ui는 2023년 등장 이후 React 생태계를 뒤흔들었습니다. "라이브러리가 아닌 컴포넌트 컬렉션"이라는 독특한 철학으로, 2026년 현재 가장 인기 있는 UI 솔루션이 되었습니다.
shadcn/ui란?
핵심 철학
- 복사 기반: npm 패키지가 아닌 소스 코드를 프로젝트에 복사
- 완전한 소유권: 내 코드처럼 자유롭게 수정 가능
- 의존성 최소화: Radix UI + Tailwind CSS만 사용
- 접근성 내장: ARIA 표준 준수
설치 및 초기 설정
1. 프로젝트 초기화
# Next.js 프로젝트 생성
npx create-next-app@latest my-app --typescript --tailwind --eslint
cd my-app
# shadcn/ui 초기화
npx shadcn@latest init
2. 설정 선택
✔ Which style would you like to use? › New York
✔ Which color would you like to use as base color? › Zinc
✔ Do you want to use CSS variables for colors? › yes
3. 생성된 구조
my-app/
├── components/
│ └── ui/ # shadcn 컴포넌트 위치
├── lib/
│ └── utils.ts # cn() 유틸리티
├── components.json # shadcn 설정
└── tailwind.config.ts
4. cn() 유틸리티
// lib/utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 사용 예시
<div className={cn(
"px-4 py-2",
isActive && "bg-primary",
className
)} />
컴포넌트 추가하기
개별 설치
# 버튼 추가
npx shadcn@latest add button
# 여러 컴포넌트 한 번에
npx shadcn@latest add card dialog form input
전체 설치
# 모든 컴포넌트 설치
npx shadcn@latest add --all
설치 후 구조
components/
└── ui/
├── button.tsx
├── card.tsx
├── dialog.tsx
├── form.tsx
└── input.tsx
핵심 컴포넌트 사용법
Button
import { Button } from "@/components/ui/button"
export function ButtonDemo() {
return (
<div className="flex gap-4">
<Button>기본</Button>
<Button variant="secondary">보조</Button>
<Button variant="destructive">위험</Button>
<Button variant="outline">아웃라인</Button>
<Button variant="ghost">고스트</Button>
<Button variant="link">링크</Button>
{/* 크기 */}
<Button size="sm">작게</Button>
<Button size="lg">크게</Button>
{/* 아이콘 */}
<Button>
<Mail className="mr-2 h-4 w-4" />
이메일 보내기
</Button>
{/* 로딩 */}
<Button disabled>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
처리 중...
</Button>
</div>
)
}
Card
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export function CardDemo() {
return (
<Card className="w-[350px]">
<CardHeader>
<CardTitle>알림 설정</CardTitle>
<CardDescription>알림 수신 방법을 선택하세요</CardDescription>
</CardHeader>
<CardContent>
{/* 콘텐츠 */}
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">취소</Button>
<Button>저장</Button>
</CardFooter>
</Card>
)
}
Dialog (모달)
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export function DialogDemo() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">프로필 수정</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>프로필 수정</DialogTitle>
<DialogDescription>
프로필 정보를 수정하세요. 완료 후 저장을 클릭하세요.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<Input placeholder="이름" />
<Input placeholder="이메일" />
</div>
<DialogFooter>
<Button type="submit">저장</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Form (React Hook Form 연동)
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
const formSchema = z.object({
username: z.string().min(2, "2자 이상 입력하세요"),
email: z.string().email("올바른 이메일을 입력하세요"),
})
export function FormDemo() {
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-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>사용자명</FormLabel>
<FormControl>
<Input placeholder="홍길동" {...field} />
</FormControl>
<FormDescription>공개적으로 표시됩니다</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">제출</Button>
</form>
</Form>
)
}
테마 커스터마이징
CSS 변수 시스템
/* globals.css */
@layer base {
:root {
--background: 0 0% 100%;
--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%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--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%;
/* ... 다크모드 색상 */
}
}
브랜드 색상 적용
:root {
/* 브랜드 블루 */
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
/* 둥근 모서리 */
--radius: 0.75rem;
}
컴포넌트 수정
// components/ui/button.tsx
const buttonVariants = cva(
"inline-flex items-center justify-center ...",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
// 커스텀 변형 추가
brand: "bg-gradient-to-r from-blue-500 to-purple-500 text-white",
},
size: {
default: "h-10 px-4 py-2",
// 커스텀 사이즈 추가
xl: "h-14 px-8 text-lg",
},
},
}
)
다크모드 구현
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>
{children}
</ThemeProvider>
)
}
테마 토글 버튼
"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">테마 변경</span>
</Button>
)
}
다른 UI 라이브러리 비교
| 항목 | shadcn/ui | MUI | Chakra UI |
|---|---|---|---|
| 설치 방식 | 소스 복사 | npm 패키지 | npm 패키지 |
| 번들 크기 | 최소 (사용 분만) | 큼 | 중간 |
| 커스텀 | 무제한 | 테마 내 | props 기반 |
| 스타일링 | Tailwind | Emotion | Emotion |
| 학습 곡선 | 낮음 | 높음 | 중간 |
| 타입스크립트 | 완벽 | 완벽 | 좋음 |
실전 팁
1. 컴포넌트 확장
// 커스텀 버튼 만들기
import { Button, ButtonProps } from "@/components/ui/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>
)
}
2. 컴포넌트 조합
// 검색 입력 컴포넌트
import { Input } from "@/components/ui/input"
import { Search, X } from "lucide-react"
export function SearchInput({ value, onChange, onClear }) {
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={onChange}
className="pl-10 pr-10"
placeholder="검색..."
/>
{value && (
<button
onClick={onClear}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
)}
</div>
)
}
3. 자주 쓰는 조합 저장
// components/ui/page-header.tsx
export function PageHeader({ title, description, children }) {
return (
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground">{description}</p>
)}
</div>
{children}
</div>
)
}
마치며
shadcn/ui의 핵심 가치:
- 소유권: 내 프로젝트의 일부로 완전히 통합
- 유연성: 디자인 시스템에 맞게 자유롭게 수정
- 성능: 필요한 컴포넌트만 포함
- 개발 경험: Tailwind + TypeScript의 강력한 조합
"라이브러리를 쓰지 말고, 복사해서 내 것으로 만들어라"는 철학이 2026년 프론트엔드 트렌드를 이끌고 있습니다.