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

1216 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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