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

32 KiB
Raw Blame History

shadcn/ui 완전 구현 가이드

이 문서는 shadcn/ui 공식 문서(https://ui.shadcn.com)를 철저히 분석하여 작성되었습니다.

목차

  1. 설치 및 초기 설정
  2. CSS 변수 및 테마 설정
  3. 컴포넌트별 구현 가이드
  4. 문제 해결 (Troubleshooting)

1. 설치 및 초기 설정

1.1 필수 패키지 설치

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 스타일을 따릅니다.

// 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 사용):

/* 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 필수 설정

/* 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)

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 형식을 사용합니다:

/* ✅ 올바른 방법 (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 설정

: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 컴포넌트

// 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 };

사용법:

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 패키지 설치

# shadcn/ui 기본 컴포넌트만 필요
npm install lucide-react
# react-day-picker는 불필요!

3.2.2 컴포넌트 코드

파일 위치: components/ui/custom-calendar.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 기본 사용법

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 크기 조절

{
  /* 작은 크기 */
}
<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 결합)

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 패키지 설치

npm install @radix-ui/react-dialog

3.3.2 컴포넌트 코드

// 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 표준 사용 패턴

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 스타일 혼용
  • 해결:
    // 올바른 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함

  • 해결:

    /* ❌ 제거해야 함 */
    @import "react-day-picker/style.css";
    
    /* ✅ shadcn/ui는 자체 Tailwind 스타일만 사용 */
    

증상 3: 색상이 공식 문서와 다름

  • 원인: 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가 해당 파일을 스캔하지 못함

해결:

// 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 스타일을 덮어씀

해결:

/* ❌ 글로벌 스타일로 덮어쓰지 말 것 */
button {
  background-color: blue !important;
}

/* ✅ 특정 클래스에만 적용 */
.my-custom-button {
  background-color: blue;
}

5. 베스트 프랙티스

5.1 컴포넌트 커스터마이징

공식 shadcn/ui 컴포넌트는 수정하지 말고, 래핑하여 확장:

// ❌ 나쁜 예: 직접 수정
// 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 변수 커스터마이징

/* ✅ 프로젝트별 색상 테마 정의 */
:root {
  /* Primary를 브랜드 색상으로 */
  --primary: 200 100% 50%; /* 파란색 */
  --primary-foreground: 0 0% 100%; /* 흰색 */

  /* 나머지는 공식 기본값 유지 */
  --secondary: 210 40% 96.1%;
  /* ... */
}

5.3 반응형 디자인

// shadcn/ui는 모바일 우선 (Mobile-First)
<div className="flex-col md:flex-row">
  {/* 모바일: 세로 배치, 데스크톱: 가로 배치 */}
</div>

<Dialog>
  <DialogContent className="sm:max-w-[425px]">
    {/* 모바일: 전체 너비, 데스크톱: 425px */}
  </DialogContent>
</Dialog>

5.4 다크 모드 지원

// 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를 사용하면 가능합니다:

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를 구현할 수 있습니다.


참고 자료