1216 lines
32 KiB
Markdown
1216 lines
32 KiB
Markdown
# 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
|