shadcn/ui 완벽 가이드 — 복사해서 쓰는 컴포넌트 라이브러리

shadcnUIReactTailwindCSS컴포넌트디자인시스템

shadcn/ui는 2023년 등장 이후 React 생태계를 뒤흔들었습니다. "라이브러리가 아닌 컴포넌트 컬렉션"이라는 독특한 철학으로, 2026년 현재 가장 인기 있는 UI 솔루션이 되었습니다.


shadcn/ui란?

핵심 철학

  1. 복사 기반: npm 패키지가 아닌 소스 코드를 프로젝트에 복사
  2. 완전한 소유권: 내 코드처럼 자유롭게 수정 가능
  3. 의존성 최소화: Radix UI + Tailwind CSS만 사용
  4. 접근성 내장: 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/uiMUIChakra UI
설치 방식소스 복사npm 패키지npm 패키지
번들 크기최소 (사용 분만)중간
커스텀무제한테마 내props 기반
스타일링TailwindEmotionEmotion
학습 곡선낮음높음중간
타입스크립트완벽완벽좋음

실전 팁

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의 핵심 가치:

  1. 소유권: 내 프로젝트의 일부로 완전히 통합
  2. 유연성: 디자인 시스템에 맞게 자유롭게 수정
  3. 성능: 필요한 컴포넌트만 포함
  4. 개발 경험: Tailwind + TypeScript의 강력한 조합

"라이브러리를 쓰지 말고, 복사해서 내 것으로 만들어라"는 철학이 2026년 프론트엔드 트렌드를 이끌고 있습니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.