# shadcn/ui 완전 구현 가이드 > 이 문서는 shadcn/ui 공식 문서(https://ui.shadcn.com)를 철저히 분석하여 작성되었습니다. ## 목차 1. [설치 및 초기 설정](#1-설치-및-초기-설정) 2. [CSS 변수 및 테마 설정](#2-css-변수-및-테마-설정) 3. [컴포넌트별 구현 가이드](#3-컴포넌트별-구현-가이드) 4. [문제 해결 (Troubleshooting)](#4-문제-해결-troubleshooting) --- ## 1. 설치 및 초기 설정 ### 1.1 필수 패키지 설치 ```bash npm install tailwindcss@latest npm install class-variance-authority clsx tailwind-merge npm install @radix-ui/react-* # 필요한 Radix UI 컴포넌트들 npm install lucide-react # 아이콘 ``` ### 1.2 Tailwind CSS 설정 **중요**: shadcn/ui는 Tailwind CSS v4를 사용할 수 있지만, 기본적으로 v3 스타일을 따릅니다. ```js // tailwind.config.js (v3 스타일) module.exports = { darkMode: ["class"], content: [ "./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}", ], theme: { container: { center: true, padding: "2rem", screens: { "2xl": "1400px", }, }, extend: { colors: { border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", background: "hsl(var(--background))", foreground: "hsl(var(--foreground))", primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))", }, secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))", }, destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", }, muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))", }, accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))", }, popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))", }, card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))", }, }, borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", }, }, }, plugins: [require("tailwindcss-animate")], }; ``` **Tailwind CSS v4 스타일 (@theme 사용):** ```css /* app/globals.css */ @import "tailwindcss"; @theme { --color-background: hsl(var(--background)); --color-foreground: hsl(var(--foreground)); --color-card: hsl(var(--card)); --color-card-foreground: hsl(var(--card-foreground)); --color-primary: hsl(var(--primary)); --color-primary-foreground: hsl(var(--primary-foreground)); /* ... 나머지 색상 */ } ``` ### 1.3 globals.css 필수 설정 ```css /* app/globals.css */ @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-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%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 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%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } ``` **중요한 점:** 1. CSS 변수는 **HSL 형식**으로 작성 (예: `222.2 47.4% 11.2%`) 2. `hsl()` 함수는 **사용하지 않음** (Tailwind가 자동으로 추가) 3. 공백으로 구분 (쉼표 없음) ### 1.4 유틸리티 함수 (lib/utils.ts) ```typescript import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ``` 이 함수는 **모든 shadcn/ui 컴포넌트에서 필수**입니다. --- ## 2. CSS 변수 및 테마 설정 ### 2.1 색상 시스템 이해 shadcn/ui는 **시맨틱 색상 시스템**을 사용합니다: | 색상 변수 | 용도 | 예시 | | ---------------------- | ------------------------- | ---------------------------- | | `--background` | 페이지 배경 | 흰색 (라이트), 검은색 (다크) | | `--foreground` | 기본 텍스트 | 검은색 (라이트), 흰색 (다크) | | `--primary` | 주요 액션 (버튼, 선택 등) | 진한 네이비/검은색 | | `--primary-foreground` | Primary 위의 텍스트 | 흰색 | | `--secondary` | 보조 액션 | 연한 회색 | | `--accent` | 강조, 호버 효과 | 연한 파란색 | | `--muted` | 비활성/보조 배경 | 밝은 회색 | | `--muted-foreground` | 보조 텍스트 (설명 등) | 중간 회색 | | `--destructive` | 삭제/에러 액션 | 빨간색 | | `--border` | 테두리 | 밝은 회색 | | `--input` | 입력 필드 테두리 | 밝은 회색 | | `--ring` | 포커스 링 | 검은색 | ### 2.2 HSL vs OKLCH 주의사항 **공식 shadcn/ui는 HSL 형식을 사용합니다:** ```css /* ✅ 올바른 방법 (HSL) */ --primary: 222.2 47.4% 11.2%; /* ❌ 잘못된 방법 (OKLCH) */ --primary: oklch(0.205 0 0); ``` 만약 OKLCH를 사용하려면: 1. 모든 컴포넌트에서 `hsl()` → `oklch()` 변경 필요 2. Tailwind 설정 수정 필요 3. 공식 문서와 다른 색상으로 보일 수 있음 **권장: 공식 HSL 형식 그대로 사용** ### 2.3 Border Radius 설정 ```css :root { --radius: 0.5rem; /* 8px, 공식 기본값 */ } ``` 컴포넌트에서 사용: - `rounded-lg`: `var(--radius)` = 8px - `rounded-md`: `calc(var(--radius) - 2px)` = 6px - `rounded-sm`: `calc(var(--radius) - 4px)` = 4px --- ## 3. 컴포넌트별 구현 가이드 ### 3.1 Button 컴포넌트 ```tsx // 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 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 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 hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, 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, VariantProps { asChild?: boolean; } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( ); } ); Button.displayName = "Button"; export { Button, buttonVariants }; ``` **사용법:** ```tsx import { Button } from "@/components/ui/button"; ``` ### 3.2 Calendar 컴포넌트 (자체 제작 - 완벽 구현) #### 3.2.1 문제점 및 해결 방안 **react-day-picker v9 문제:** - 요일 헤더 간격이 이상하게 보임 (MoTuWeThFrSa) - 클래스명이 변경되어 스타일 적용이 어려움 - `captionLayout="dropdown"`이 제대로 작동하지 않음 **해결책: 자체 Calendar 컴포넌트 제작** - shadcn/ui 스타일을 완벽하게 재현 - 월/연도 드롭다운 선택 기능 내장 - 크기 조절 가능 (`sm`, `default`, `lg`) #### 3.2.2 패키지 설치 ```bash # shadcn/ui 기본 컴포넌트만 필요 npm install lucide-react # react-day-picker는 불필요! ``` #### 3.2.2 컴포넌트 코드 **파일 위치:** `components/ui/custom-calendar.tsx` ```tsx "use client"; import * as React from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; interface CustomCalendarProps { selected?: Date; onSelect?: (date: Date | undefined) => void; className?: string; mode?: "single"; size?: "sm" | "default" | "lg"; } const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; const MONTHS = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; export function CustomCalendar({ selected, onSelect, className, mode = "single", size = "default", }: CustomCalendarProps) { // 크기별 클래스 정의 const sizeClasses = { sm: { cell: "h-7 w-7 text-xs", header: "text-xs", day: "text-[0.7rem]", nav: "h-6 w-6", }, default: { cell: "h-9 w-9 text-sm", header: "text-[0.8rem]", day: "text-sm", nav: "h-7 w-7", }, lg: { cell: "h-11 w-11 text-base", header: "text-sm", day: "text-base", nav: "h-8 w-8", }, }; const currentSize = sizeClasses[size]; const [currentDate, setCurrentDate] = React.useState(selected || new Date()); const [viewYear, setViewYear] = React.useState(currentDate.getFullYear()); const [viewMonth, setViewMonth] = React.useState(currentDate.getMonth()); const getDaysInMonth = (year: number, month: number) => { return new Date(year, month + 1, 0).getDate(); }; const getFirstDayOfMonth = (year: number, month: number) => { return new Date(year, month, 1).getDay(); }; const generateCalendarDays = () => { const daysInMonth = getDaysInMonth(viewYear, viewMonth); const firstDay = getFirstDayOfMonth(viewYear, viewMonth); const daysInPrevMonth = getDaysInMonth(viewYear, viewMonth - 1); const days: Array<{ date: number; month: "prev" | "current" | "next"; fullDate: Date; }> = []; // Previous month days for (let i = firstDay - 1; i >= 0; i--) { const date = daysInPrevMonth - i; days.push({ date, month: "prev", fullDate: new Date(viewYear, viewMonth - 1, date), }); } // Current month days for (let i = 1; i <= daysInMonth; i++) { days.push({ date: i, month: "current", fullDate: new Date(viewYear, viewMonth, i), }); } // Next month days const remainingDays = 42 - days.length; for (let i = 1; i <= remainingDays; i++) { days.push({ date: i, month: "next", fullDate: new Date(viewYear, viewMonth + 1, i), }); } return days; }; const handlePrevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1); } else { setViewMonth(viewMonth - 1); } }; const handleNextMonth = () => { if (viewMonth === 11) { setViewMonth(0); setViewYear(viewYear + 1); } else { setViewMonth(viewMonth + 1); } }; const handleDateClick = (date: Date) => { if (onSelect) { onSelect(date); } }; const isToday = (date: Date) => { const today = new Date(); return ( date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear() ); }; const isSelected = (date: Date) => { if (!selected) return false; return ( date.getDate() === selected.getDate() && date.getMonth() === selected.getMonth() && date.getFullYear() === selected.getFullYear() ); }; const calendarDays = generateCalendarDays(); return (
{/* Header with Month/Year Dropdowns */}
{/* Month Select */} {/* Year Select */}
{/* Days of week */}
{DAYS.map((day) => (
{day}
))}
{/* Calendar grid */}
{calendarDays.map((day, index) => { const isOutside = day.month !== "current"; const isTodayDate = isToday(day.fullDate); const isSelectedDate = isSelected(day.fullDate); return ( ); })}
); } CustomCalendar.displayName = "CustomCalendar"; ``` #### 3.2.3 주요 기능 **1. 월/연도 드롭다운 선택** - 월: January ~ December 전체 선택 가능 - 연도: 현재 연도 ±50년 범위 (총 100년) - shadcn/ui Select 컴포넌트 사용 **2. 크기 조절 (`size` prop)** - `sm`: 28px × 28px 셀 (작은 크기) - `default`: 36px × 36px 셀 (기본 크기) - `lg`: 44px × 44px 셀 (큰 크기) **3. 완벽한 요일 간격** - `grid grid-cols-7`로 정확히 7개 컬럼 - Su Mo Tu We Th Fr Sa 간격 완벽 **4. shadcn/ui 스타일** - 선택된 날짜: Primary 배경 (검은색) - 오늘 날짜: Accent 배경 (연한 파란색) - 외부 날짜: Muted 색상, 50% 투명도 - 호버 효과: Ghost 버튼 스타일 #### 3.2.4 기본 사용법 ```tsx import { CustomCalendar } from "@/components/ui/custom-calendar"; import { useState } from "react"; export function CalendarDemo() { const [date, setDate] = useState(new Date()); return ( ); } ``` #### 3.2.5 크기 조절 ```tsx { /* 작은 크기 */ } ; { /* 기본 크기 */ } ; { /* 큰 크기 */ } ; ``` #### 3.2.6 Date Picker (Popover 결합) ```tsx import { CustomCalendar } from "@/components/ui/custom-calendar"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { useState } from "react"; export function DatePickerDemo() { const [date, setDate] = useState(); return ( ); } ``` **장점:** - ✅ 월/연도 드롭다운 선택 가능 - ✅ 완벽한 요일 간격 - ✅ shadcn/ui 스타일 완벽 재현 - ✅ `date-fns` 불필요 (순수 JavaScript) ### 3.3 Dialog (Modal) 컴포넌트 #### 3.3.1 패키지 설치 ```bash npm install @radix-ui/react-dialog ``` #### 3.3.2 컴포넌트 코드 ```tsx // components/ui/dialog.tsx "use client"; import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; const Dialog = DialogPrimitive.Root; const DialogTrigger = DialogPrimitive.Trigger; const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( {children} Close )); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
); DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
); DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogDescription.displayName = DialogPrimitive.Description.displayName; export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, }; ``` #### 3.3.3 표준 사용 패턴 ```tsx import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; export function DialogDemo() { return ( 프로필 수정 프로필 정보를 수정합니다. 완료되면 저장을 클릭하세요.
); } ``` --- ## 4. 문제 해결 (Troubleshooting) ### 4.1 Calendar가 이상하게 보이는 경우 #### 증상 1: 드롭다운이 두 개로 보임 - **원인**: `react-day-picker` v8 스타일과 v9 스타일 혼용 - **해결**: ```tsx // 올바른 v9 스타일 classNames 사용 classNames={{ caption: "flex justify-center pt-1 relative items-center", caption_label: "text-sm font-medium", // ... v9 클래스명 사용 }} ``` #### 증상 2: 선택된 날짜가 파란 테두리로 보임 - **원인**: `react-day-picker/style.css`를 import함 - **해결**: ```css /* ❌ 제거해야 함 */ @import "react-day-picker/style.css"; /* ✅ shadcn/ui는 자체 Tailwind 스타일만 사용 */ ``` #### 증상 3: 색상이 공식 문서와 다름 - **원인**: CSS 변수가 잘못 설정됨 - **해결**: ```css /* ✅ 올바른 HSL 형식 */ --primary: 222.2 47.4% 11.2%; /* ❌ 잘못된 OKLCH 형식 */ --primary: oklch(0.205 0 0); ``` #### 증상 4: 드롭다운 화살표가 없음 - **원인**: `captionLayout="dropdown"`을 사용했지만 커스텀 CSS가 없음 - **해결**: `react-day-picker` v9는 자동으로 네이티브 `