Calendar

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview
2026년 5월
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1

Calendar

날짜 선택을 위한 달력 컴포넌트입니다.
휴일 표시, 노출 기한(선택 가능 범위), 월 이동, 오늘/선택 주 강조 등 다양한 props로 제어할 수 있습니다.

Calendar Options

휴일 표시, 선택 범위 제한, 시작 요일 변경 등 다양한 설정을 지원합니다.

Basic
<Calendar value={date} onChange={setDate} />
20266
Default (Sunday Start)
Live Preview
Holidays
<Calendar holidays={["2026-02-01", ...]} showHolidays />
20262
Red Holidays
Live Preview
Date Range
<Calendar displayStartDate={start} displayEndDate={end} />
20262
Restricted Range
Live Preview
Start on Monday
<Calendar firstDayOfWeek={1} />
20266
Monday Start
Live Preview

Calendar Modal Variations

이미지 예시처럼 모달 안에서 날짜를 선택할 수 있거나, 뒤로 가기 버튼을 포함할 수 있습니다.

Basic Modal
<CalendarModal open={open} onClose={...} title="날짜선택" ... />
선택: 미선택
Live Preview
With Back Button
<CalendarModal open={open} onClose={...} onBack={...} title="날짜선택" ... />
Header에 뒤로가기 버튼이 추가됩니다.
Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Calendartypescript
'use client';

import { useState, useMemo } from 'react';
import { cn } from '@/lib/cn';

/** YYYY-MM-DD 형식으로 날짜 키 생성 */
function toDateKey(d: Date): string {
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}

/** 문자열 또는 Date를 Date로 변환 */
function toDate(val: Date | string): Date {
  if (val instanceof Date) return val;
  return new Date(val + 'T00:00:00');
}

/** 두 날짜가 같은 년/월/일인지 */
function isSameDay(a: Date, b: Date): boolean {
  return toDateKey(a) === toDateKey(b);
}

/** 주(week)가 같은지 (일요일 시작 기준) */
function isSameWeek(a: Date, b: Date): boolean {
  const sunA = new Date(a);
  sunA.setDate(a.getDate() - a.getDay());
  const sunB = new Date(b);
  sunB.setDate(b.getDate() - b.getDay());
  return toDateKey(sunA) === toDateKey(sunB);
}

/** 해당 월의 캘린더 그리드용 날짜 배열 (이전/다음 달 패딩 포함) */
function getCalendarDays(year: number, month: number, firstDayOfWeek: 0 | 1): Date[] {
  const first = new Date(year, month, 1);
  const last = new Date(year, month + 1, 0);
  const startOffset = firstDayOfWeek === 0 ? first.getDay() : (first.getDay() + 6) % 7;
  const start = new Date(first);
  start.setDate(start.getDate() - startOffset);

  const days: Date[] = [];
  const totalCells = Math.ceil((last.getDate() + startOffset) / 7) * 7;
  for (let i = 0; i < totalCells; i++) {
    const d = new Date(start);
    d.setDate(start.getDate() + i);
    days.push(d);
  }
  return days;
}

const DAY_LABELS = ['일', '월', '화', '수', '목', '금', '토'];

export interface CalendarProps {
  /** 선택된 날짜 */
  value?: Date | null;
  /** 날짜 선택 시 콜백 */
  onChange?: (date: Date) => void;
  /** 초기 표시 월 */
  initialMonth?: Date;
  /** 선택 가능 최소 날짜 */
  minDate?: Date;
  /** 선택 가능 최대 날짜 */
  maxDate?: Date;
  /** 휴일 (빨간색 등 스타일) - YYYY-MM-DD 문자열 또는 Date 배열 */
  holidays?: (Date | string)[];
  /** 노출 기한: 이 범위 밖의 날짜는 비활성화 */
  displayStartDate?: Date;
  /** 노출 기한: 이 범위 밖의 날짜는 비활성화 */
  displayEndDate?: Date;
  /** 주의 첫 날 (0: 일요일, 1: 월요일) */
  firstDayOfWeek?: 0 | 1;
  /** 월 이동 버튼 표시 여부 */
  showMonthNavigation?: boolean;
  /** 오늘 날짜 강조 */
  highlightToday?: boolean;
  /** 선택된 날이 포함된 주 강조 */
  highlightSelectedWeek?: boolean;
  /** 휴일 표시 여부 */
  showHolidays?: boolean;
  /** 커스텀 비활성화 함수 */
  disableDates?: (date: Date) => boolean;
  /** 날짜별 커스텀 클래스 */
  getDateClassName?: (date: Date) => string | undefined;
  className?: string;
}

function ChevronLeft({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M15 18l-6-6 6-6" />
    </svg>
  );
}

function ChevronRight({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M9 18l6-6-6-6" />
    </svg>
  );
}

export function Calendar({
  value,
  onChange,
  initialMonth = new Date(),
  minDate,
  maxDate,
  holidays = [],
  displayStartDate,
  displayEndDate,
  firstDayOfWeek = 0,
  showMonthNavigation = true,
  highlightToday = true,
  highlightSelectedWeek = true,
  showHolidays = true,
  disableDates,
  getDateClassName,
  className,
}: CalendarProps) {
  const [viewDate, setViewDate] = useState(() => {
    const d = new Date(initialMonth);
    d.setDate(1);
    return d;
  });

  const holidaySet = useMemo(() => {
    const set = new Set<string>();
    holidays.forEach((h) => set.add(toDateKey(toDate(h))));
    return set;
  }, [holidays]);

  const isDisabled = (date: Date): boolean => {
    if (minDate && date < minDate && !isSameDay(date, minDate)) return true;
    if (maxDate && date > maxDate && !isSameDay(date, maxDate)) return true;
    if (displayStartDate && date < displayStartDate && !isSameDay(date, displayStartDate)) return true;
    if (displayEndDate && date > displayEndDate && !isSameDay(date, displayEndDate)) return true;
    if (disableDates?.(date)) return true;
    return false;
  };

  const isCurrentMonth = (date: Date): boolean =>
    date.getMonth() === viewDate.getMonth();

  const isToday = (date: Date): boolean => {
    const today = new Date();
    return isSameDay(date, today);
  };

  const isSelected = (date: Date): boolean =>
    value != null && isSameDay(date, value);

  const isHoliday = (date: Date): boolean =>
    showHolidays && holidaySet.has(toDateKey(date));

  const isInSelectedWeek = (date: Date): boolean =>
    value != null && highlightSelectedWeek && isSameWeek(date, value);

  const goPrevMonth = () => {
    setViewDate((d) => {
      const next = new Date(d);
      next.setMonth(next.getMonth() - 1);
      return next;
    });
  };

  const goNextMonth = () => {
    setViewDate((d) => {
      const next = new Date(d);
      next.setMonth(next.getMonth() + 1);
      return next;
    });
  };

  const handleDateClick = (date: Date) => {
    if (isDisabled(date)) return;
    onChange?.(new Date(date.getFullYear(), date.getMonth(), date.getDate()));
  };

  const dayHeaders =
    firstDayOfWeek === 0 ? DAY_LABELS : [...DAY_LABELS.slice(1), DAY_LABELS[0]];
  const days = getCalendarDays(
    viewDate.getFullYear(),
    viewDate.getMonth(),
    firstDayOfWeek,
  );

  return (
    <div className={cn('w-full rounded-2xl border border-slate-200/80 bg-white p-5 shadow-lg ring-1 ring-black/5', className)}>
      {/* 월 네비게이션 */}
      {showMonthNavigation && (
        <div className="mb-5 flex items-center justify-between">
          <button
            type="button"
            onClick={goPrevMonth}
            className="flex size-10 items-center justify-center rounded-xl text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 active:scale-95"
            aria-label="이전 달"
          >
            <ChevronLeft className="size-5" />
          </button>
          <span className="text-lg font-bold tracking-tight text-slate-900">
            {viewDate.getFullYear()}년 {viewDate.getMonth() + 1}월
          </span>
          <button
            type="button"
            onClick={goNextMonth}
            className="flex size-10 items-center justify-center rounded-xl text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 active:scale-95"
            aria-label="다음 달"
          >
            <ChevronRight className="size-5" />
          </button>
        </div>
      )}

      {/* 요일 헤더 */}
      <div className="mb-2 grid grid-cols-7 gap-1 text-center">
        {dayHeaders.map((label) => (
          <div
            key={label}
            className={cn(
              'py-2 text-xs font-semibold uppercase tracking-wider',
              label === '일' && 'text-red-500',
              label === '토' && 'text-blue-500',
              label !== '일' && label !== '토' && 'text-slate-500',
            )}
          >
            {label}
          </div>
        ))}
      </div>

      {/* 날짜 그리드 */}
      <div className="grid grid-cols-7 gap-1.5">
        {days.map((date) => {
          const currentMonth = isCurrentMonth(date);
          const disabled = isDisabled(date);
          const today = highlightToday && isToday(date);
          const selected = isSelected(date);
          const holiday = isHoliday(date);
          const inSelectedWeek = isInSelectedWeek(date);
          const customClass = getDateClassName?.(date);

          const isSunday = date.getDay() === 0;
          const isSaturday = date.getDay() === 6;

          return (
            <button
              key={toDateKey(date)}
              type="button"
              disabled={disabled}
              onClick={() => handleDateClick(date)}
              className={cn(
                'flex size-10 items-center justify-center rounded-xl text-sm font-medium transition-all duration-200',
                !currentMonth && 'text-slate-300',
                currentMonth && !disabled && !selected && 'text-slate-800',
                disabled && 'cursor-not-allowed text-slate-300',
                !disabled && currentMonth && 'hover:bg-slate-100 hover:scale-105',
                today && currentMonth && !disabled && !selected && 'ring-2 ring-slate-400 ring-offset-2 font-bold text-slate-900',
                inSelectedWeek && currentMonth && !disabled && !selected && 'bg-slate-50 font-semibold',
                selected && 'bg-slate-900 text-white shadow-md hover:bg-slate-800 hover:shadow-lg scale-105',
                holiday && currentMonth && !selected && !disabled && 'text-red-600',
                !holiday && isSunday && currentMonth && !selected && !disabled && 'text-red-500',
                !holiday && isSaturday && currentMonth && !selected && !disabled && 'text-blue-500',
                customClass,
              )}
            >
              {date.getDate()}
            </button>
          );
        })}
      </div>
    </div>
  );
}