콘텐츠로 건너뛰기

캘린더

예약, 일정 관리 및 기간 선택을 위한 월 단위 그리드 날짜 선택기 — 네오브루탈리즘 스타일의 테두리와 그림자 효과가 적용되었습니다.

"use client"

import * as React from "react"

캘린더는 날짜 하나, 여러 날짜, 또는 기간을 고를 수 있는 월 단위 그리드를 렌더링합니다. React DayPicker를 기반으로 하며(두 백엔드 변형 모두 같은 라이브러리를 감쌉니다), 네오브루탈리즘 공식(두꺼운 테두리, 선명한 그림자, 굵은 서체)에 맞춰 스타일을 입혔습니다.

다음과 같은 경우에 사용합니다.

  • 폼의 날짜 필드 — 팝오버와 짝지어 생일, 마감일, 배송일을 고르는 Date Picker를 만듭니다.
  • 예약 흐름 — 체크인/체크아웃에는 mode="range"를, 이미 예약된 날에는 disabled를 씁니다.
  • 대시보드 필터 — 리포트 기간, 예약 가능 현황, "최근 7일" 같은 프리셋 단축 항목.

설치

pnpm dlx shadcn@latest add https://neobrutalism.com/r/base/calendar.json
npx shadcn@latest add https://neobrutalism.com/r/base/calendar.json
yarn dlx shadcn@latest add https://neobrutalism.com/r/base/calendar.json
bunx --bun shadcn@latest add https://neobrutalism.com/r/base/calendar.json

다음 의존성을 설치합니다:

pnpm add react-day-picker date-fns
npm install react-day-picker date-fns
yarn add react-day-picker date-fns
bun add react-day-picker date-fns

Button 컴포넌트를 프로젝트에 추가합니다.

Calendar 컴포넌트는 Button 컴포넌트를 사용합니다. 프로젝트에 설치되어 있는지 확인하세요.

다음 코드를 복사해 프로젝트에 붙여넣습니다.

components/ui/calendar.tsx
"use client"

import * as React from "react"
import {
  ChevronDownIcon,
  ChevronLeftIcon,
  ChevronRightIcon,
} from "lucide-react"
import {
  DayPicker,
  getDefaultClassNames,
  type DayButton,
  type Locale,
} from "react-day-picker"

import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"

function Calendar({
  className,
  classNames,
  showOutsideDays = true,
  fixedWeeks = true,
  captionLayout = "label",
  buttonVariant = "ghost",
  locale,
  formatters,
  components,
  ...props
}: React.ComponentProps<typeof DayPicker> & {
  buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
  const defaultClassNames = getDefaultClassNames()

  return (
    <DayPicker
      showOutsideDays={showOutsideDays}
      fixedWeeks={fixedWeeks}
      className={cn(
        "group/calendar bg-card p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(8)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
        String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
        String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
        className
      )}
      captionLayout={captionLayout}
      locale={locale}
      formatters={{
        formatMonthDropdown: (date, dateLib) =>
          dateLib
            ? dateLib.format(date, "LLL")
            : date.toLocaleString(locale?.code, { month: "short" }),
        ...formatters,
      }}
      classNames={{
        root: cn("w-fit", defaultClassNames.root),
        months: cn(
          "relative flex flex-col gap-4 md:flex-row",
          defaultClassNames.months
        ),
        month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
        nav: cn(
          "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
          defaultClassNames.nav
        ),
        button_previous: cn(
          buttonVariants({ variant: buttonVariant }),
          "size-(--cell-size) rounded border-2 p-0 shadow-sm select-none aria-disabled:opacity-50",
          defaultClassNames.button_previous
        ),
        button_next: cn(
          buttonVariants({ variant: buttonVariant }),
          "size-(--cell-size) rounded border-2 p-0 shadow-sm select-none aria-disabled:opacity-50",
          defaultClassNames.button_next
        ),
        month_caption: cn(
          "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
          defaultClassNames.month_caption
        ),
        dropdowns: cn(
          "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
          defaultClassNames.dropdowns
        ),
        dropdown_root: cn(
          "cn-calendar-dropdown-root relative rounded-(--cell-radius)",
          defaultClassNames.dropdown_root
        ),
        dropdown: cn(
          "absolute inset-0 bg-popover opacity-0",
          defaultClassNames.dropdown
        ),
        caption_label: cn(
          "font-head font-medium select-none",
          captionLayout === "label"
            ? "cn-calendar-caption text-sm"
            : "cn-calendar-caption-label flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
          defaultClassNames.caption_label
        ),
        month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
        weekdays: cn("flex", defaultClassNames.weekdays),
        weekday: cn(
          "flex w-(--cell-size) items-center justify-center rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
          defaultClassNames.weekday
        ),
        week: cn("mt-2 flex w-full", defaultClassNames.week),
        week_number_header: cn(
          "w-(--cell-size) select-none",
          defaultClassNames.week_number_header
        ),
        week_number: cn(
          "text-[0.8rem] text-muted-foreground select-none",
          defaultClassNames.week_number
        ),
        day: cn(
          "group/day relative size-(--cell-size) rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
          props.showWeekNumber
            ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
            : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
          defaultClassNames.day
        ),
        range_start: cn(
          "relative isolate z-0 rounded-l-(--cell-radius) bg-primary/30 after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-primary/30",
          defaultClassNames.range_start
        ),
        range_middle: cn("rounded-none", defaultClassNames.range_middle),
        range_end: cn(
          "relative isolate z-0 rounded-r-(--cell-radius) bg-primary/30 after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-primary/30",
          defaultClassNames.range_end
        ),
        today: cn(
          "rounded-(--cell-radius) border-2 border-primary text-foreground data-[selected=true]:rounded-none",
          defaultClassNames.today
        ),
        outside: cn(
          "text-muted-foreground aria-selected:text-muted-foreground",
          defaultClassNames.outside
        ),
        disabled: cn(
          "text-muted-foreground opacity-50",
          defaultClassNames.disabled
        ),
        hidden: cn("invisible", defaultClassNames.hidden),
        ...classNames,
      }}
      components={{
        Root: ({ className, rootRef, ...props }) => {
          return (
            <div
              data-slot="calendar"
              ref={rootRef}
              className={cn(className)}
              {...props}
            />
          )
        },
        Chevron: ({ className, orientation, ...props }) => {
          if (orientation === "left") {
            return (
              <ChevronLeftIcon
                className={cn("cn-rtl-flip size-4", className)}
                {...props}
              />
            )
          }

          if (orientation === "right") {
            return (
              <ChevronRightIcon
                className={cn("cn-rtl-flip size-4", className)}
                {...props}
              />
            )
          }

          return (
            <ChevronDownIcon className={cn("size-4", className)} {...props} />
          )
        },
        DayButton: ({ ...props }) => (
          <CalendarDayButton locale={locale} {...props} />
        ),
        WeekNumber: ({ children, ...props }) => {
          return (
            <td {...props}>
              <div className="flex size-(--cell-size) items-center justify-center text-center">
                {children}
              </div>
            </td>
          )
        },
        ...components,
      }}
      {...props}
    />
  )
}

function CalendarDayButton({
  className,
  day,
  modifiers,
  locale,
  ...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
  const defaultClassNames = getDefaultClassNames()

  const ref = React.useRef<HTMLButtonElement>(null)
  React.useEffect(() => {
    if (modifiers.focused) ref.current?.focus()
  }, [modifiers.focused])

  return (
    <Button
      ref={ref}
      variant="ghost"
      size="icon"
      data-day={`${day.date.getFullYear()}-${String(
        day.date.getMonth() + 1
      ).padStart(2, "0")}-${String(day.date.getDate()).padStart(2, "0")}`}
      data-selected-single={
        modifiers.selected &&
        !modifiers.range_start &&
        !modifiers.range_end &&
        !modifiers.range_middle
      }
      data-range-start={modifiers.range_start}
      data-range-end={modifiers.range_end}
      data-range-middle={modifiers.range_middle}
      className={cn(
        "relative isolate z-10 flex size-(--cell-size) flex-col items-center justify-center gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:outline-2 group-data-[focused=true]/day:outline-offset-2 group-data-[focused=true]/day:outline-primary data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:border-2 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-primary/30 data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:border-2 data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:border-2 data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
        defaultClassNames.day,
        className
      )}
      {...props}
    />
  )
}

export { Calendar, CalendarDayButton }

프로젝트 설정에 맞게 import 경로를 업데이트합니다.

사용법

import { Calendar } from "@/components/ui/calendar"
const [date, setDate] = React.useState<Date | undefined>(new Date())
 
return (
  <Calendar
    mode="single"
    selected={date}
    onSelect={setDate}
    className="rounded border-2 shadow-md"
  />
)

자세한 내용은 React DayPicker 문서를 참조하세요.

소개

Calendar 컴포넌트는 React DayPicker를 기반으로 구축되었습니다.

Date Picker

<Calendar> 컴포넌트를 사용해 날짜 선택기를 만들 수 있습니다. 자세한 내용은 Date Picker 페이지를 참조하세요.

페르시아 / 히즈리 / 잘랄리 달력

페르시아 달력을 사용하려면 components/ui/calendar.tsx를 편집하고 react-day-pickerreact-day-picker/persian으로 교체합니다.

- import { DayPicker } from "react-day-picker"
+ import { DayPicker } from "react-day-picker/persian"
"use client"

import * as React from "react"

선택된 날짜 (타임존 포함)

Calendar 컴포넌트는 timeZone prop을 받아, 사용자의 로컬 타임존으로 날짜가 표시되고 선택되도록 합니다.

export function CalendarWithTimezone() {
  const [date, setDate] = React.useState<Date | undefined>(undefined)
  const [timeZone, setTimeZone] = React.useState<string | undefined>(undefined)
 
  React.useEffect(() => {
    setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone)
  }, [])
 
  return (
    <Calendar
      mode="single"
      selected={date}
      onSelect={setDate}
      timeZone={timeZone}
    />
  )
}

참고: 선택한 날짜에 오프셋이 보이는 경우(예: 20일을 선택하면 19일이 강조됨), timeZone prop이 사용자의 로컬 타임존으로 설정되어 있는지 확인하세요.

왜 클라이언트 측인가요? 타임존은 서버 사이드 렌더링과의 호환성을 보장하기 위해 useEffect 안에서 Intl.DateTimeFormat().resolvedOptions().timeZone을 사용해 감지됩니다. 렌더링 중에 타임존을 감지하면, 서버와 클라이언트가 서로 다른 타임존에 있을 수 있으므로 하이드레이션 불일치가 발생합니다.

예제

기본

기본적인 캘린더 컴포넌트입니다. 캘린더 스타일에는 className="rounded border-2 shadow-md"를 사용했습니다.

"use client"

import { Calendar } from "@/components/ui/calendar"

범위 캘린더

mode="range" prop을 사용해 범위 선택을 활성화합니다.

"use client"

import * as React from "react"

월과 연도 선택기

captionLayout="dropdown"을 사용해 월과 연도 드롭다운을 표시합니다.

"use client"

import { Calendar } from "@/components/ui/calendar"

프리셋

"use client"

import * as React from "react"

날짜와 시간 선택기

"use client"

import * as React from "react"

예약된 날짜

"use client"

import * as React from "react"

커스텀 셀 크기

"use client"

import * as React from "react"

--cell-size CSS 변수를 사용해 캘린더 셀의 크기를 커스터마이즈할 수 있습니다. 브레이크포인트별 값을 사용해 반응형으로 만들 수도 있습니다:

<Calendar
  mode="single"
  selected={date}
  onSelect={setDate}
  className="rounded border-2 shadow-md [--cell-size:--spacing(11)] md:[--cell-size:--spacing(12)]"
/>

또는 고정값을 사용합니다:

<Calendar
  mode="single"
  selected={date}
  onSelect={setDate}
  className="rounded border-2 shadow-md [--cell-size:2.75rem] md:[--cell-size:3rem]"
/>

주 번호

showWeekNumber를 사용해 주 번호를 표시합니다.

"use client"

import * as React from "react"

RTL

Neobrutalism에서 RTL을 활성화하는 방법은 RTL 설정 가이드를 참조하세요.

페르시아 / 히즈리 / 잘랄리 달력을 활성화하는 방법은 히즈리 달력 가이드도 참조하세요.

"use client"

import * as React from "react"

RTL을 사용할 때는 react-day-picker/locale에서 로케일을 import하고, localedir prop을 모두 Calendar 컴포넌트에 전달합니다:

import { arSA } from "react-day-picker/locale"
 
;<Calendar
  mode="single"
  selected={date}
  onSelect={setDate}
  locale={arSA}
  dir="rtl"
/>

접근성

캘린더는 WAI-ARIA Date Picker Dialog 패턴의 그리드 탐색 모델을 따릅니다. React DayPicker가 한 달을 role="grid"로 렌더링하고 각 날짜를 실제 버튼으로 만들며, 선택된 날짜와 비활성화된 날짜를 스크린 리더에 알려 줍니다. 두 백엔드 변형은 같은 라이브러리를 감싸므로 이 동작을 그대로 공유합니다.

키보드 상호작용:

동작
Tab / Shift + Tab내비게이션 버튼과 날짜 그리드 사이로 포커스 이동
ArrowLeft / ArrowRight이전/다음 날짜로 포커스 이동
ArrowUp / ArrowDown이전/다음 주의 같은 요일로 포커스 이동
Home / End해당 주의 첫째/마지막 날로 포커스 이동
PageUp / PageDown이전/다음 달로 이동
Shift + PageUp / Shift + PageDown이전/다음 해로 이동
Space / Enter포커스된 날짜 선택

API 참조

Calendar 컴포넌트에 대한 자세한 내용은 React DayPicker 문서를 참조하세요.

변경 로그

RTL 지원

이전 버전의 Calendar 컴포넌트에서 업그레이드하는 경우, 로케일 지원을 추가하려면 다음 업데이트를 적용해야 합니다:

Locale 타입을 import합니다.

react-day-picker에서 가져오는 import에 Locale을 추가합니다:

  import {
    DayPicker,
    getDefaultClassNames,
    type DayButton,
+   type Locale,
  } from "react-day-picker"

Calendar 컴포넌트에 locale prop을 추가합니다.

컴포넌트의 props에 locale prop을 추가합니다:

  function Calendar({
    className,
    classNames,
    showOutsideDays = true,
    captionLayout = "label",
    buttonVariant = "ghost",
+   locale,
    formatters,
    components,
    ...props
  }: React.ComponentProps<typeof DayPicker> & {
    buttonVariant?: React.ComponentProps<typeof Button>["variant"]
  }) {

locale을 DayPicker에 전달합니다.

locale prop을 DayPicker 컴포넌트에 전달합니다:

    <DayPicker
      showOutsideDays={showOutsideDays}
      className={cn(...)}
      captionLayout={captionLayout}
+     locale={locale}
      formatters={{
        formatMonthDropdown: (date) =>
-         date.toLocaleString("default", { month: "short" }),
+         date.toLocaleString(locale?.code, { month: "short" }),
        ...formatters,
      }}

CalendarDayButton이 locale을 받도록 업데이트합니다.

CalendarDayButton 컴포넌트 시그니처를 업데이트하고 locale을 전달합니다:

  function CalendarDayButton({
    className,
    day,
    modifiers,
+   locale,
    ...props
- }: React.ComponentProps<typeof DayButton>) {
+ }: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {

CalendarDayButton의 날짜 포맷을 업데이트합니다.

날짜 포맷에서 locale?.code를 사용합니다:

    <Button
      variant="ghost"
      size="icon"
-     data-day={day.date.toLocaleDateString()}
+     data-day={day.date.toLocaleDateString(locale?.code)}
      ...
    />

locale을 DayButton 컴포넌트에 전달합니다.

DayButton 컴포넌트 사용부를 업데이트하여 locale prop을 전달합니다:

      components={{
        ...
-       DayButton: CalendarDayButton,
+       DayButton: ({ ...props }) => (
+         <CalendarDayButton locale={locale} {...props} />
+       ),
        ...
      }}

RTL을 고려한 CSS 클래스를 업데이트합니다.

더 나은 RTL 지원을 위해 방향 클래스를 논리 속성으로 교체합니다:

  // In the day classNames:
- [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)
+ [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)
- [&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)
+ [&:nth-child(2)[data-selected=true]_button]:rounded-s-(--cell-radius)
- [&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)
+ [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius)
 
  // In range_start classNames:
- rounded-l-(--cell-radius) ... after:right-0
+ rounded-s-(--cell-radius) ... after:end-0
 
  // In range_end classNames:
- rounded-r-(--cell-radius) ... after:left-0
+ rounded-e-(--cell-radius) ... after:start-0
 
  // In CalendarDayButton className:
- data-[range-end=true]:rounded-r-(--cell-radius)
+ data-[range-end=true]:rounded-e-(--cell-radius)
- data-[range-start=true]:rounded-l-(--cell-radius)
+ data-[range-start=true]:rounded-s-(--cell-radius)

이러한 변경을 적용하면, locale prop을 사용해 로케일별 포맷을 제공할 수 있습니다:

import { enUS } from "react-day-picker/locale"
 
;<Calendar mode="single" selected={date} onSelect={setDate} locale={enUS} />