대부분의 UI 컴포넌트 라이브러리는 npm 패키지로 설치합니다. 그러나 이 방식에는 한계가 있습니다. 내부 구현을 수정하려면 패키지를 포크하거나 복잡한 오버라이드가 필요합니다. 버전 업데이트 시 의도치 않은 변경이 생기기도 합니다.
shadcn/ui는 다른 방식을 선택합니다. npm에 설치하는 것이 아니라, CLI로 컴포넌트 소스 코드를 프로젝트에 직접 복사합니다. 복사된 코드는 완전히 내 것이며 자유롭게 수정할 수 있습니다. Radix UI의 접근성과 Tailwind CSS의 스타일링, class-variance-authority의 변형 관리를 조합한 고품질 컴포넌트를 바탕으로 삼습니다.
npx shadcn@latest init
이 명령은 몇 가지 질문 후 components.json 설정 파일을 생성하고, cn 유틸리티와 기본 CSS 변수를 설정합니다.
// 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"
}
}
shadcn/ui의 핵심 특징은 패키지 설치 대신 소스 코드 복사입니다. CLI가 레지스트리에서 컴포넌트 코드를 가져와 프로젝트 디렉터리에 씁니다.
# 단일 컴포넌트 추가
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add input
# 복수 컴포넌트 한 번에 추가
npx shadcn@latest add button dialog input select
# 모든 컴포넌트 추가
npx shadcn@latest add --all
명령 실행 후 src/components/ui/button.tsx 같은 파일이 생성됩니다. 이 파일은 직접 편집할 수 있습니다. shadcn/ui는 이 파일을 다시 덮어쓰지 않습니다.
CLI 실행
|
v
레지스트리에서 소스 가져옴
|
v
필요한 Radix 패키지 npm install
|
v
src/components/ui/button.tsx 생성 (내 코드)
shadcn/ui 레지스트리는 각 컴포넌트의 소스, 필요한 npm 패키지, 스타일 등의 메타데이터를 관리합니다. CLI는 이 레지스트리를 참조하여 컴포넌트를 가져옵니다.
// 레지스트리 항목 구조 예시
{
"name": "button",
"type": "registry:ui",
"dependencies": ["@radix-ui/react-slot"],
"devDependencies": [],
"files": [
{
"path": "ui/button.tsx",
"content": "..."
}
]
}
커스텀 레지스트리를 구성하여 팀 내부 컴포넌트를 배포하는 것도 가능합니다.
# 커스텀 레지스트리에서 컴포넌트 추가
npx shadcn@latest add https://my-registry.com/components/custom-chart.json
shadcn/ui 컴포넌트는 Radix Primitive 위에 Tailwind 클래스를 씌운 구조입니다. 추가 후 소스 코드를 직접 확인하고 수정할 수 있습니다.
// 추가된 Button 컴포넌트 (src/components/ui/button.tsx)
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium " +
"ring-offset-background transition-colors focus-visible:outline-none " +
"focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
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",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
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",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
cva는 컴포넌트 변형을 체계적으로 관리합니다. 기본 클래스와 변형 옵션을 분리하여 선언하고, TypeScript 타입이 자동으로 생성됩니다.
import { cva, type VariantProps } from "class-variance-authority";
const alertVariants = cva(
// 기본 클래스 — 모든 변형에 공통 적용
"relative w-full rounded-lg border p-4 flex gap-3",
{
variants: {
variant: {
default: "bg-background text-foreground border-border",
success: "bg-green-50 text-green-900 border-green-200",
warning: "bg-yellow-50 text-yellow-900 border-yellow-200",
destructive: "bg-red-50 text-red-900 border-red-200",
},
},
defaultVariants: {
variant: "default",
},
}
);
interface AlertProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof alertVariants> {}
function Alert({ className, variant, ...props }: AlertProps) {
return (
<div className={cn(alertVariants({ variant }), className)} {...props} />
);
}
// 사용 예시
<Alert variant="success">저장되었습니다.</Alert>
<Alert variant="destructive">오류가 발생했습니다.</Alert>
shadcn/ui는 Tailwind 클래스를 하드코딩하는 대신 CSS 변수를 사용합니다. 변수 값만 바꾸면 모든 컴포넌트 색상이 함께 바뀝니다.
/* globals.css */
@layer base {
:root {
--background: 0 0% 100%;
--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%;
--destructive: 0 84.2% 60.2%;
--border: 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%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
/* ... */
}
}
// tailwind.config.ts — CSS 변수를 Tailwind에 연결
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
},
},
}
이 구조 덕분에 bg-primary는 :root의 --primary 값을 따릅니다. 다크 모드에서는 .dark에 정의된 변수 값으로 자동 전환됩니다.
shadcn/ui 공식 사이트에서 테마를 시각적으로 선택하고 CSS 변수를 복사할 수 있습니다.
shadcn/ui 컴포넌트는 대부분 asChild prop을 지원합니다. 이를 통해 HTML 요소나 다른 컴포넌트와 자연스럽게 조합됩니다.
import { Button } from "@/components/ui/button";
import { Link } from "react-router-dom";
// 기본 — <button> 렌더링
<Button variant="default" size="lg">
클릭
</Button>
// asChild — <a> 태그에 Button 스타일 적용
<Button asChild variant="outline">
<a href="https://example.com" target="_blank">
외부 링크
</a>
</Button>
// asChild — React Router Link에 Button 스타일 적용
<Button asChild>
<Link to="/dashboard">대시보드</Link>
</Button>
복잡한 컴포넌트도 합성으로 조합합니다.
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function EditProfileDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">프로필 편집</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>프로필 편집</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">이름</Label>
<Input id="name" className="col-span-3" />
</div>
</div>
<DialogFooter>
<Button type="submit">저장</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
shadcn/ui 컴포넌트는 Radix Primitive 위에 구축되어 있으므로, Radix가 처리하는 모든 접근성이 자동으로 포함됩니다.
// Select 컴포넌트 예시 — 키보드 탐색, aria-* 자동 처리
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
function CountrySelect() {
return (
<Select>
{/* 자동으로 aria-haspopup, aria-expanded 처리 */}
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="국가 선택" />
</SelectTrigger>
<SelectContent>
{/* Arrow Key 탐색, Enter/Space 선택 자동 처리 */}
<SelectItem value="kr">대한민국</SelectItem>
<SelectItem value="us">미국</SelectItem>
<SelectItem value="jp">일본</SelectItem>
</SelectContent>
</Select>
);
}
추가적으로 shadcn/ui 컴포넌트는 Label과 폼 요소의 연결, aria-describedby를 통한 오류 메시지 연결 등을 편리하게 구성할 수 있는 구조를 제공합니다.
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="email">이메일</Label>
<Input
type="email"
id="email"
placeholder="example@email.com"
aria-describedby="email-error"
/>
<p id="email-error" className="text-sm text-destructive">
유효한 이메일 주소를 입력해 주세요.
</p>
</div>
shadcn/ui는 컴포넌트 라이브러리가 아닙니다. 공식 문서의 표현처럼 "복사해서 붙여넣는 컴포넌트 컬렉션"입니다.
| 개념 | 역할 |
|---|---|
| CLI | 소스 코드 복사 도구, npm install 대신 파일을 프로젝트에 씀 |
| Registry | 컴포넌트 소스와 의존성 정보 저장소 |
| Component | Radix + Tailwind로 조합된 완성형 UI 컴포넌트 |
| Variant | cva로 타입 안전하게 정의한 컴포넌트 변형 |
| Theme | CSS 변수로 전체 색상 팔레트를 한 번에 교체 |
| Composition | asChild로 동작을 자식 요소에 위임 |
| Accessibility | Radix 기반으로 WAI-ARIA 자동 보장 |
코드가 내 프로젝트 안에 있기 때문에 외부 패키지 업데이트에 영향을 받지 않습니다. 필요한 컴포넌트만 선택하고, 자유롭게 수정하며, 팀의 디자인 시스템에 맞게 발전시킬 수 있습니다.