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
2026년 6월
일
월
화
수
목
금
토
Live Preview
Holidays
2026년 2월
일
월
화
수
목
금
토
Live Preview
Date Range
2026년 2월
일
월
화
수
목
금
토
Live Preview
Start on Monday
2026년 6월
월
화
수
목
금
토
일
Live Preview
Calendar Modal Variations
이미지 예시처럼 모달 안에서 날짜를 선택할 수 있거나, 뒤로 가기 버튼을 포함할 수 있습니다.
Basic Modal
선택: 미선택
Live Preview
With Back Button
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>
);
}