shadcn/ui: 설치 대신 소스를 복사하는 컴포넌트 키트, 테마·다크 모드, CLI, 커스터마이징
이 글의 핵심
shadcn/ui는 npm 패키지를 설치하는 대신 Radix UI와 Tailwind CSS 기반 컴포넌트 소스를 내 코드베이스로 가져오는 방식입니다. 그래서 수정은 자유롭지만 업데이트는 직접 diff로 확인해야 하는 트레이드오프가 있습니다. 테마 설정과 핵심 컴포넌트, 커스텀 Registry, 접근성과 번들 크기까지 실제 앱 구성 관점에서 살펴봅니다.
설치 (Next.js 15 기준)
pnpm dlx shadcn@latest init
# 질문에 답: TS, app/ dir, Tailwind v4, components/ui 위치, CSS variables for theme, etc.
생성/변경 파일:
components.json— shadcn CLI 설정lib/utils.ts—cn()유틸app/globals.css— Tailwind + 디자인 토큰tailwind.config.ts— Tailwind v3 프로젝트에서만 사용. v4에서는 설정이 CSS(@theme)로 옮겨가 보통 생성되지 않습니다
init이 하는 일은 생각보다 단순합니다. 컴포넌트가 import할 경로 별칭(@/components, @/lib/utils)을 components.json에 기록하고, 모든 컴포넌트가 쓰는 cn() 헬퍼와 디자인 토큰 CSS를 만들어 둡니다. 이후 add 명령은 이 설정을 읽어 레지스트리에서 받은 소스의 import 경로를 내 프로젝트에 맞게 바꿔 씁니다. 그래서 tsconfig.json의 paths에 @/* 별칭이 없으면 add는 성공해도 빌드에서 Module not found: Can't resolve '@/lib/utils' 에러가 납니다. Vite 프로젝트에서 이 에러를 자주 보는데, tsconfig.json뿐 아니라 vite.config.ts의 resolve.alias에도 같은 별칭을 넣어야 하기 때문입니다.
cn()은 clsx로 조건부 클래스를 합친 뒤 tailwind-merge로 충돌하는 클래스를 정리합니다. cn("px-4", "px-2")가 "px-2"만 남기는 덕분에, 컴포넌트 기본 클래스를 호출하는 쪽의 className으로 안전하게 덮어쓸 수 있습니다. 문자열을 단순히 이어 붙이면 px-4 px-2가 둘 다 남고, 어느 쪽이 이기는지는 CSS 파일 안의 선언 순서에 달려 예측하기 어렵습니다.
첫 컴포넌트
pnpm dlx shadcn@latest add button dialog input label
components/ui/button.tsx·dialog.tsx·input.tsx·label.tsx 생성. 이후 import { Button } from "@/components/ui/button" 로 사용.
import { SunIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
export default function Page() {
return (
<div className="p-8 space-x-2">
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Delete</Button>
<Button size="lg">Large</Button>
<Button size="icon"><SunIcon /></Button>
</div>
)
}
테마 (CSS 변수)
/* app/globals.css */
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--accent: oklch(0.97 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
/* ... */
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
/* ... */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
이 변수들이 bg-primary·text-primary-foreground·border-input·rounded-lg 같은 Tailwind 클래스를 만듭니다.
구조가 두 층으로 나뉜다는 점이 중요합니다. :root와 .dark의 --primary 같은 변수는 실제 색 값을 담는 층이고, @theme inline은 그 변수를 Tailwind가 유틸리티 클래스로 만들 수 있는 이름(--color-primary)에 연결하는 층입니다. inline 키워드는 Tailwind가 var(--primary)를 또 다른 변수로 감싸지 않고 그대로 박아 넣게 해서, .dark 아래에서 --primary 값만 바뀌어도 bg-primary가 즉시 따라 바뀝니다. 새 색을 추가할 때 :root에만 변수를 넣고 @theme inline 매핑을 빠뜨리면 bg-brand 같은 클래스가 아예 생성되지 않는데, 에러 없이 스타일만 빠지기 때문에 원인을 찾는 데 시간이 걸립니다.
OKLCH를 쓰는 이유는 명도(L)가 사람 눈에 보이는 밝기와 비례해서, 같은 L 값을 유지한 채 색상(H)만 바꿔도 대비가 거의 유지되기 때문입니다. HSL은 같은 명도 값이라도 노랑과 파랑의 체감 밝기가 크게 달라서 팔레트를 만들 때 대비를 다시 맞춰야 합니다.
색 테마 바꾸기
기본 색 계열(neutral, zinc, slate 등)은 init 단계에서 고릅니다. 나중에 바꾸려면 공식 사이트의 테마 페이지에서 원하는 팔레트의 CSS 변수를 복사해 globals.css의 :root/.dark 블록을 교체하면 됩니다. 컴포넌트 코드는 모두 bg-primary 같은 의미 기반 클래스만 쓰므로 컴포넌트 파일은 건드릴 필요가 없습니다.
다크 모드
Next.js에서는 next-themes를 설치하고, 공식 문서의 ThemeProvider 래퍼와 ModeToggle 예제 코드를 components/에 직접 만들어 씁니다. 두 파일 모두 레지스트리 컴포넌트가 아니라 문서에 있는 짧은 예제입니다.
pnpm add next-themes
// components/theme-provider.tsx
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
)
}
import { ModeToggle } from "@/components/mode-toggle"
<header><ModeToggle /></header>
next-themes가 시스템 설정 감지, 수동 토글, localStorage 저장을 처리하고 <html>에 dark 클래스를 붙입니다. suppressHydrationWarning이 필요한 이유는, 서버는 사용자의 테마를 모른 채 HTML을 만들고 클라이언트에서 스크립트가 class="dark"를 붙이기 때문에 <html> 속성이 서버 렌더링 결과와 달라지기 때문입니다. 이 속성이 없으면 콘솔에 hydration mismatch 경고가 뜹니다. 이 속성은 해당 요소 한 단계에만 적용되므로 자식 요소의 진짜 불일치 경고는 그대로 보입니다.
다크 모드를 처음 붙일 때 흔히 겪는 문제는 토글 아이콘의 hydration 경고입니다. ModeToggle에서 theme === "dark" ? <Moon/> : <Sun/>처럼 현재 테마로 분기하면 서버와 클라이언트가 다른 아이콘을 렌더링합니다. 공식 예제가 두 아이콘을 모두 렌더링하고 dark: 클래스로 하나를 숨기는 이유가 이것입니다.
핵심 컴포넌트 카탈로그
Dialog
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
<Dialog>
<DialogTrigger asChild><Button>Open</Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Confirm</DialogTitle></DialogHeader>
<p>Are you sure?</p>
</DialogContent>
</Dialog>
Radix 기반으로 포커스 트랩, ESC로 닫기, 열릴 때 포커스 이동과 닫힐 때 트리거로 포커스 복귀, role="dialog"와 aria-labelledby 연결이 처리됩니다. asChild는 Radix의 Slot 패턴으로, DialogTrigger가 자체 <button>을 만들지 않고 자식인 Button에 이벤트와 ARIA 속성을 합쳐 줍니다. 이를 빼먹으면 <button> 안에 <button>이 들어가 validateDOMNesting 경고가 납니다.
DialogTitle을 생략하면 Radix가 콘솔에 DialogContent requires a DialogTitle for the component to be accessible for screen reader users. 경고를 냅니다. 디자인상 제목을 보이고 싶지 않다면 지우지 말고 sr-only 클래스로 시각적으로만 숨기는 것이 맞습니다.
Form + react-hook-form + zod
pnpm dlx shadcn@latest add form input label button
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
type FormValues = z.infer<typeof schema>
export function LoginForm() {
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "" },
})
function onSubmit(values: FormValues) {
console.log(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 max-w-sm">
<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="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl><Input type="password" {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Sign in</Button>
</form>
</Form>
)
}
shadcn의 Form은 새로운 폼 라이브러리가 아니라 react-hook-form의 FormProvider를 감싼 얇은 계층입니다. FormField는 Controller를 감싸고, FormItem이 만든 고유 id를 FormLabel(htmlFor), FormControl(id, aria-describedby, aria-invalid), FormMessage(에러 메시지 id)에 자동으로 나눠 줍니다. 레이블과 에러 메시지를 스크린 리더와 연결하는 번거로운 작업을 이 구조가 대신하는 셈입니다. 검증 규칙은 zod 스키마 한 곳에만 있고 z.infer로 폼 값 타입까지 뽑아내므로, 필드를 추가할 때 스키마만 고치면 타입 에러가 나머지 수정할 곳을 알려 줍니다. zod와 react-hook-form 자체의 동작은 react-hook-form 글과 zod 글에서 더 자세히 다룹니다.
defaultValues를 빈 문자열로 준 것도 이유가 있습니다. 값이 undefined로 시작하면 Input이 비제어 컴포넌트로 시작했다가 입력 후 제어 컴포넌트로 바뀌어 A component is changing an uncontrolled input to be controlled 경고가 납니다.
Data Table (TanStack Table)
pnpm dlx shadcn@latest add table
pnpm add @tanstack/react-table
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type User = { id: number; name: string; email: string }
const columns: ColumnDef<User>[] = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
]
export function UsersTable({ data }: { data: User[] }) {
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() })
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>{flexRender(h.column.columnDef.header, h.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>
)
}
table 컴포넌트는 스타일을 입힌 <table> 태그 묶음일 뿐이고, 정렬·필터·페이지네이션 로직은 모두 TanStack Table에서 옵니다. TanStack Table은 마크업을 전혀 렌더링하지 않는 headless 라이브러리라서 두 가지를 조합해야 하는데, 공식 문서의 Data Table 페이지는 설치할 컴포넌트가 아니라 이 조합 방법을 설명하는 가이드입니다. 정렬이 필요하면 getSortedRowModel(), 페이지네이션이면 getPaginationRowModel()을 useReactTable 옵션에 추가하고 상태를 연결하는 식으로 기능을 하나씩 붙입니다. columns를 컴포넌트 안에서 매 렌더마다 새로 만들면 테이블이 계속 재계산되므로, 위처럼 컴포넌트 밖에 두거나 useMemo로 고정합니다.
Toast (sonner)
pnpm dlx shadcn@latest add sonner
import { Toaster } from "@/components/ui/sonner" // add sonner가 만든 테마 연동 래퍼
import { toast } from "sonner"
<Toaster richColors /> // 루트 레이아웃에 한 번만
<Button onClick={() => toast.success("Saved")}>Save</Button>
Command (Command Palette)
pnpm dlx shadcn@latest add command
command는 cmdk 라이브러리를 감싼 컴포넌트로, 입력한 문자열로 항목을 필터링하고 방향키로 이동하는 Cmd+K 팔레트를 만듭니다. CommandDialog와 함께 쓰고 keydown 이벤트에서 (e.metaKey || e.ctrlKey) && e.key === "k"를 감지해 여는 것이 일반적인 구성입니다. 기본 필터링은 클라이언트에서 모든 항목을 대상으로 하므로, 서버 검색 결과를 보여 주려면 shouldFilter={false}로 내장 필터를 끄고 직접 결과를 넣어야 합니다. 이를 끄지 않으면 서버가 돌려준 결과가 다시 걸러져 일부 항목이 사라지는 문제가 생깁니다.
CLI 고급
업데이트 diff 확인
pnpm dlx shadcn@latest diff button
# 내 프로젝트의 button.tsx와 최신 레지스트리 비교 (CLI 버전에 따라 지원 여부가 다름)
diff를 지원하지 않는 버전이라면 git이 가장 확실한 도구입니다. 작업 트리를 깨끗하게 커밋한 뒤 add button --overwrite로 최신 소스를 덮어쓰고, git diff로 내 수정과 upstream 변경을 비교해 필요한 부분만 남기면 됩니다. 이 과정이 번거롭다는 것이 소스 복사 모델의 실제 비용입니다. 내 코드가 되었으니 버그 수정도 자동으로 들어오지 않습니다. 그래서 컴포넌트를 수정할 때 원본 구조는 최대한 유지하고, 바꾼 부분에 짧은 주석을 남겨 두면 나중에 upstream 변경을 합치기가 훨씬 쉽습니다.
커스텀 Registry
// components.json (v2)
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"tailwind": { "css": "app/globals.css" },
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
},
"registries": {
"@my-team": "https://ui.my-team.com/r/{name}.json"
}
}
pnpm dlx shadcn@latest add @my-team/company-button
레지스트리는 컴포넌트 소스와 의존성 목록을 담은 JSON 파일을 정적 호스팅하는 것에 불과합니다. 사내 공용 컴포넌트도 같은 CLI로 배포할 수 있어 npm 패키지를 따로 퍼블리시하지 않아도 됩니다. 다만 받은 코드는 결국 각 프로젝트에 복사되므로 “공통 패키지를 한 번 고치면 모든 앱이 바뀐다”는 효과는 없다는 점을 알고 선택해야 합니다.
커스터마이징 패턴
cva (class variance authority)로 variant 정의
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
)
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ variant, size, className }))} {...props} />
}
cva는 variant 이름과 클래스 목록의 매핑을 선언하는 작은 라이브러리입니다. VariantProps<typeof buttonVariants>가 variant와 size prop의 유니언 타입을 자동으로 만들어 주므로, variants.variant에 success: "..." 한 줄을 추가하면 <Button variant="success">가 바로 타입 검사를 통과하고 오타는 컴파일 단계에서 걸립니다. className을 buttonVariants()에 함께 넘기고 cn()으로 감싸는 부분이 중요한데, 이 덕분에 호출하는 쪽에서 className="h-12"를 주면 기본 h-9가 tailwind-merge에 의해 제거됩니다.
브랜딩 맞춤
--primary·--radius·--font-sans만 바꿔도 전체 키트 룩이 변경- 로고·아이콘은 Lucide(
lucide-react)를 기본으로 사용
v0과의 연계
Vercel의 v0가 생성하는 코드는 shadcn/ui 컴포넌트와 같은 규약(경로 별칭, CSS 변수 토큰)을 따르므로, v0가 제공하는 URL을 npx shadcn@latest add <url>로 받아 프로젝트에 넣을 수 있습니다. AI가 만든 코드는 동작 확인 없이 들어오기 쉬우니, 추가된 파일의 의존성(package.json 변경)과 접근성 속성을 리뷰한 뒤 합치는 편이 안전합니다.
실전 앱 구성 예시
Admin Dashboard
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<Header />
<main className="p-6 space-y-6">
<StatsCards />
<DataTable />
</main>
</SidebarInset>
</SidebarProvider>
sidebar·card·skeleton·avatar·dropdown-menu와 table(+ TanStack Table) 조합이 어드민 화면의 기본 골격이 됩니다. SidebarProvider는 사이드바 열림 상태를 쿠키에 저장해 서버 렌더링 시에도 이전 상태를 유지할 수 있게 해 줍니다.
SaaS 마케팅
navigation-menu·hover-card로 상단 메뉴 구성accordion(FAQ)·tabs(기능 비교)·card(요금제)- 히어로·마퀴·요금표 같은 섹션은 공식 컴포넌트가 아니므로
card·button등을 조합하거나 서드파티 레지스트리에서 가져옵니다
접근성
Radix 기반이라 대화상자·메뉴·탭 같은 인터랙티브 컴포넌트는 WAI-ARIA Authoring Practices에 맞춘 키보드 네비게이션, 포커스 관리, ARIA 속성이 기본으로 들어 있습니다.
하지만 이것이 앱 전체의 접근성을 보장하지는 않습니다. 색 대비는 내가 정한 CSS 변수 값에 달려 있고, 아이콘만 있는 버튼(size="icon")에는 aria-label이나 sr-only 텍스트를 직접 넣어야 하며, 이미지 대체 텍스트와 페이지 구조(heading 순서, landmark)도 컴포넌트가 대신해 주지 않습니다. 소스를 직접 수정할 수 있다는 장점이 여기서는 위험이 되기도 합니다. DialogTitle을 지우거나 asChild 구조를 바꾸면 Radix가 제공하던 연결이 끊어집니다.
트러블슈팅
CSS 변수가 적용 안 됨
@theme inline에 매핑 누락- Tailwind v4 플러그인이 로드됐는지
dark클래스가html에 붙는지
컴포넌트 스타일이 다른 요소와 충돌
- Tailwind Preflight이 필요 →
@import "tailwindcss"가 entry에 - 기존 CSS 리셋 중복 여부 확인
size prop 커스터마이징 어려움
button.tsx의buttonVariants를 직접 수정
Dialog가 스크롤을 막음
- body scroll lock은 모달의 기본 동작입니다. 배경과 상호작용해야 한다면
modal={false}를 쓰되, 그러면 포커스 트랩도 함께 꺼진다는 점을 감안해야 합니다 - Dialog 안에서 Select나 Popover를 열었을 때 드롭다운이 잘리거나 클릭이 안 되는 문제는 대개 z-index나
overflow: hidden컨테이너 때문입니다. Radix는 이런 요소를 Portal로body에 렌더링하므로, 커스텀 CSS에서 Portal 대상의 z-index를 낮추지 않았는지 확인합니다
번들 크기
- 미사용 컴포넌트는 import하지 않으면 빌드에 포함되지 않음
- Radix는 primitive별 패키지라 tree-shaken
마무리
shadcn/ui의 핵심은 “라이브러리를 설치한다”가 아니라 “검증된 출발점 코드를 받아 내 것으로 만든다”는 모델입니다. 소스가 내 저장소에 있으니 디자인을 깊게 바꿔도 라이브러리와 싸울 일이 없고, 동작과 접근성은 Radix, 스타일은 Tailwind라는 역할 분담이 명확해서 코드를 읽고 고치기도 쉽습니다.
대신 업데이트와 버그 수정을 직접 챙겨야 하고, 컴포넌트를 수정하는 순간 그 코드의 품질 책임도 팀으로 넘어옵니다. 브랜드 디자인이 중요한 제품이나 사내 디자인 시스템의 출발점으로는 잘 맞고, 디자인 요구가 적고 복잡한 데이터 컴포넌트가 많이 필요한 내부 도구라면 완성형 라이브러리(MUI 등)가 더 빠를 수 있습니다. 토큰과 유틸리티 클래스 설계 자체는 Tailwind 컴포넌트·디자인 시스템 글에서 이어서 볼 수 있습니다.
자주 묻는 질문 (FAQ)
Q. shadcn/ui 컴포넌트를 추가했는데 색이나 다크 모드가 적용되지 않으면 무엇을 확인하나요?
A. 먼저 CSS 변수가 @theme inline에 제대로 매핑되어 있는지와 Tailwind v4 플러그인이 로드됐는지 확인합니다. 다크 모드는 html 요소에 dark 클래스가 실제로 붙는지가 핵심이고, 다른 요소와 스타일이 충돌한다면 엔트리 CSS에 @import “tailwindcss”가 있어 Preflight가 적용되는지, 기존 CSS 리셋과 중복되지 않는지 봅니다.