ERP-node/docs/shadcn-ui-완전가이드.md

1216 lines
32 KiB
Markdown
Raw Permalink Normal View History

2025-10-30 12:03:50 +09:00
# 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<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}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
```
**사용법:**
```tsx
import { Button } from "@/components/ui/button";
<Button variant="default">기본 버튼</Button>
<Button variant="destructive">삭제</Button>
<Button variant="outline">외곽선</Button>
<Button variant="ghost">투명</Button>
<Button size="sm">작은 버튼</Button>
<Button size="icon"><Plus className="h-4 w-4" /></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 (
<div className={cn("p-3", className)}>
{/* Header with Month/Year Dropdowns */}
<div className="flex items-center justify-between gap-2 pb-4">
<Button
variant="outline"
size="icon"
className={cn(
"bg-transparent p-0 opacity-50 hover:opacity-100",
currentSize.nav
)}
onClick={handlePrevMonth}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
{/* Month Select */}
<Select
value={viewMonth.toString()}
onValueChange={(value) => setViewMonth(parseInt(value))}
>
<SelectTrigger
className={cn("w-[110px] font-medium", currentSize.header)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTHS.map((month, index) => (
<SelectItem key={index} value={index.toString()}>
{month}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Year Select */}
<Select
value={viewYear.toString()}
onValueChange={(value) => setViewYear(parseInt(value))}
>
<SelectTrigger
className={cn("w-[80px] font-medium", currentSize.header)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 100 }, (_, i) => {
const year = new Date().getFullYear() - 50 + i;
return (
<SelectItem key={year} value={year.toString()}>
{year}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="icon"
className={cn(
"bg-transparent p-0 opacity-50 hover:opacity-100",
currentSize.nav
)}
onClick={handleNextMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* Days of week */}
<div className="mb-2 grid grid-cols-7 gap-0">
{DAYS.map((day) => (
<div
key={day}
className={cn(
"text-muted-foreground flex items-center justify-center font-normal",
currentSize.cell,
currentSize.day
)}
>
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-0">
{calendarDays.map((day, index) => {
const isOutside = day.month !== "current";
const isTodayDate = isToday(day.fullDate);
const isSelectedDate = isSelected(day.fullDate);
return (
<Button
key={index}
variant="ghost"
className={cn(
"p-0 font-normal",
currentSize.cell,
isOutside && "text-muted-foreground opacity-50",
isTodayDate &&
!isSelectedDate &&
"bg-accent text-accent-foreground",
isSelectedDate &&
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
)}
onClick={() => handleDateClick(day.fullDate)}
>
{day.date}
</Button>
);
})}
</div>
</div>
);
}
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<Date | undefined>(new Date());
return (
<CustomCalendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border shadow-sm"
/>
);
}
```
#### 3.2.5 크기 조절
```tsx
{
/* 작은 크기 */
}
<CustomCalendar
size="sm"
selected={date}
onSelect={setDate}
className="rounded-md border"
/>;
{
/* 기본 크기 */
}
<CustomCalendar
size="default"
selected={date}
onSelect={setDate}
className="rounded-md border"
/>;
{
/* 큰 크기 */
}
<CustomCalendar
size="lg"
selected={date}
onSelect={setDate}
className="rounded-md border"
/>;
```
#### 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<Date>();
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-[280px] justify-start text-left font-normal"
>
{date ? (
date.toLocaleDateString("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
})
) : (
<span className="text-muted-foreground">날짜를 선택하세요</span>
)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="ml-auto h-4 w-4 opacity-50"
>
<rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
<line x1="16" x2="16" y1="2" y2="6" />
<line x1="8" x2="8" y1="2" y2="6" />
<line x1="3" x2="21" y1="10" y2="10" />
</svg>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<CustomCalendar mode="single" selected={date} onSelect={setDate} />
</PopoverContent>
</Popover>
);
}
```
**장점:**
- ✅ 월/연도 드롭다운 선택 가능
- ✅ 완벽한 요일 간격
- ✅ 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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
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 (
<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">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
이름
</Label>
<Input id="name" defaultValue="홍길동" className="col-span-3" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="username" className="text-right">
사용자명
</Label>
<Input
id="username"
defaultValue="@honggildong"
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button type="submit">저장</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
---
## 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는 자동으로 네이티브 `<select>` 드롭다운을 생성하므로 추가 CSS 불필요
### 4.2 색상이 적용되지 않는 경우
#### 체크리스트:
1.`globals.css`에 CSS 변수 정의됨
2. ✅ CSS 변수가 **HSL 형식**으로 작성됨 (쉼표 없이 공백으로 구분)
3. ✅ Tailwind 설정에서 색상 확장 정의됨
4.`hsl(var(--color))` 형식으로 사용
5. ✅ 브라우저 캐시 지움 (Hard Refresh)
### 4.3 Tailwind 클래스가 작동하지 않는 경우
#### 원인:
- Tailwind가 해당 파일을 스캔하지 못함
#### 해결:
```js
// tailwind.config.js
module.exports = {
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
// ...
};
```
### 4.4 컴포넌트가 렌더링되지 않는 경우
#### 체크리스트:
1. ✅ 필요한 Radix UI 패키지 설치됨
2.`"use client"` 디렉티브 추가됨 (Next.js App Router)
3.`cn()` 유틸리티 함수 존재
4. ✅ Import 경로 올바름 (`@/components/ui/...`)
### 4.5 스타일 충돌 문제
#### 증상: 커스텀 CSS가 shadcn/ui 스타일을 덮어씀
#### 해결:
```css
/* ❌ 글로벌 스타일로 덮어쓰지 말 것 */
button {
background-color: blue !important;
}
/* ✅ 특정 클래스에만 적용 */
.my-custom-button {
background-color: blue;
}
```
---
## 5. 베스트 프랙티스
### 5.1 컴포넌트 커스터마이징
**공식 shadcn/ui 컴포넌트는 수정하지 말고, 래핑하여 확장:**
```tsx
// ❌ 나쁜 예: 직접 수정
// components/ui/button.tsx 파일을 직접 수정
// ✅ 좋은 예: 래핑하여 확장
// components/custom-button.tsx
import { Button, ButtonProps } from "@/components/ui/button";
interface CustomButtonProps extends ButtonProps {
loading?: boolean;
}
export function CustomButton({
loading,
children,
...props
}: CustomButtonProps) {
return (
<Button {...props} disabled={loading || props.disabled}>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{children}
</Button>
);
}
```
### 5.2 CSS 변수 커스터마이징
```css
/* ✅ 프로젝트별 색상 테마 정의 */
:root {
/* Primary를 브랜드 색상으로 */
--primary: 200 100% 50%; /* 파란색 */
--primary-foreground: 0 0% 100%; /* 흰색 */
/* 나머지는 공식 기본값 유지 */
--secondary: 210 40% 96.1%;
/* ... */
}
```
### 5.3 반응형 디자인
```tsx
// shadcn/ui는 모바일 우선 (Mobile-First)
<div className="flex-col md:flex-row">
{/* 모바일: 세로 배치, 데스크톱: 가로 배치 */}
</div>
<Dialog>
<DialogContent className="sm:max-w-[425px]">
{/* 모바일: 전체 너비, 데스크톱: 425px */}
</DialogContent>
</Dialog>
```
### 5.4 다크 모드 지원
```tsx
// app/layout.tsx
<html lang="ko" className={isDark ? "dark" : ""}>
<body>{children}</body>
</html>
```
모든 shadcn/ui 컴포넌트는 `.dark` 클래스로 자동 다크모드 지원
---
## 6. 자주 묻는 질문 (FAQ)
### Q1: shadcn/ui는 컴포넌트 라이브러리인가요?
**A**: 아닙니다. shadcn/ui는 **복사-붙여넣기 가능한 컴포넌트 모음**입니다. npm 패키지로 설치하지 않고, 코드를 직접 프로젝트에 복사하여 커스터마이징합니다.
### Q2: 모든 컴포넌트를 한 번에 설치할 수 있나요?
**A**: 공식 CLI를 사용하면 가능합니다:
```bash
npx shadcn-ui@latest init
npx shadcn-ui@latest add button
npx shadcn-ui@latest add calendar
```
### Q3: CSS 변수를 꼭 사용해야 하나요?
**A**: 네. shadcn/ui의 핵심은 CSS 변수 기반 테마 시스템입니다. 이를 통해 일관된 디자인과 다크모드를 쉽게 구현할 수 있습니다.
### Q4: Tailwind CSS 없이 사용할 수 있나요?
**A**: 불가능합니다. shadcn/ui는 Tailwind CSS와 완전히 통합되어 있습니다.
### Q5: 공식 문서와 색상이 다른 이유는?
**A**: CSS 변수 값이 다르기 때문입니다. 공식 기본값으로 변경하거나, 프로젝트에 맞게 커스터마이징할 수 있습니다.
---
## 7. 결론
shadcn/ui를 성공적으로 구현하기 위한 핵심:
1.**공식 코드를 그대로 사용** (임의로 수정하지 말 것)
2.**HSL 형식의 CSS 변수** 사용
3.**외부 CSS import 하지 말 것** (Tailwind만 사용)
4.**Radix UI 패키지 버전 확인**
5.**브라우저 캐시 주의** (Hard Refresh 습관화)
이 가이드를 따르면 공식 문서와 **완전히 동일한** UI를 구현할 수 있습니다.
---
## 참고 자료
- 공식 문서: https://ui.shadcn.com
- GitHub: https://github.com/shadcn-ui/ui
- Radix UI: https://www.radix-ui.com
- Tailwind CSS: https://tailwindcss.com