Lewati ke konten

Kalender

Pemilih tanggal berbasis kisi bulanan untuk pemesanan, penjadwalan, dan pemilihan rentang waktu — dengan sentuhan gaya neobrutalis berupa garis tepi dan bayangan.

"use client"

import * as React from "react"

Calendar merender grid bulan untuk memilih satu tanggal, banyak tanggal, atau sebuah rentang. Dibangun di atas React DayPicker — kedua varian backend membungkus pustaka yang sama — dan ditata dengan resep neobrutalis: garis tepi tebal, bayangan keras, dan tipografi tebal.

Gunakan saat:

  • Kolom tanggal pada formulir — padukan dengan popover untuk membangun Date Picker bagi tanggal lahir, tenggat, dan tanggal pengiriman.
  • Alur pemesananmode="range" untuk check-in/check-out, disabled untuk hari yang sudah dipesan.
  • Filter dasbor — rentang tanggal laporan, tampilan ketersediaan, dan pintasan preset seperti "7 hari terakhir".

Instalasi

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

Pasang dependensi berikut:

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

Tambahkan komponen Button ke proyek Anda.

Komponen Calendar menggunakan komponen Button. Pastikan Anda sudah memasangnya di proyek Anda.

Salin dan tempel kode berikut ke proyek Anda.

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 }

Perbarui jalur import agar sesuai dengan struktur proyek Anda.

Penggunaan

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

Lihat dokumentasi React DayPicker untuk informasi lebih lanjut.

Tentang

Komponen Calendar dibangun di atas React DayPicker.

Date Picker

Anda bisa menggunakan komponen <Calendar> untuk membangun date picker. Lihat halaman Date Picker untuk informasi lebih lanjut.

Kalender Persia / Hijri / Jalali

Untuk menggunakan kalender Persia, edit components/ui/calendar.tsx dan ganti react-day-picker dengan react-day-picker/persian.

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

import * as React from "react"

Tanggal Terpilih (Dengan Zona Waktu)

Komponen Calendar menerima prop timeZone untuk memastikan tanggal ditampilkan dan dipilih dalam zona waktu lokal pengguna.

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

Catatan: Jika Anda melihat pergeseran pada tanggal terpilih (misalnya, memilih tanggal 20 menyorot tanggal 19), pastikan prop timeZone disetel ke zona waktu lokal pengguna.

Mengapa di sisi klien? Zona waktu dideteksi menggunakan Intl.DateTimeFormat().resolvedOptions().timeZone di dalam useEffect untuk memastikan kompatibilitas dengan rendering sisi server. Mendeteksi zona waktu saat rendering akan menyebabkan ketidakcocokan hydration, karena server dan klien bisa berada di zona waktu yang berbeda.

Contoh

Dasar

Komponen kalender dasar. Kami menggunakan className="rounded border-2 shadow-md" untuk memberi gaya pada kalender.

"use client"

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

Kalender Rentang

Gunakan prop mode="range" untuk mengaktifkan pemilihan rentang.

"use client"

import * as React from "react"

Pemilih Bulan dan Tahun

Gunakan captionLayout="dropdown" untuk menampilkan dropdown bulan dan tahun.

"use client"

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

Preset

"use client"

import * as React from "react"

Pemilih Tanggal dan Waktu

"use client"

import * as React from "react"

Tanggal terpesan

"use client"

import * as React from "react"

Ukuran Sel Kustom

"use client"

import * as React from "react"

Anda bisa menyesuaikan ukuran sel kalender menggunakan variabel CSS --cell-size. Anda juga bisa membuatnya responsif dengan menggunakan nilai khusus untuk tiap breakpoint:

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

Atau gunakan nilai tetap:

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

Nomor Minggu

Gunakan showWeekNumber untuk menampilkan nomor minggu.

"use client"

import * as React from "react"

RTL

Untuk mengaktifkan dukungan RTL di Neobrutalism, lihat panduan konfigurasi RTL.

Lihat juga panduan Hijri untuk mengaktifkan kalender Persia / Hijri / Jalali.

"use client"

import * as React from "react"

Saat menggunakan RTL, impor locale dari react-day-picker/locale dan oper prop locale dan dir ke komponen Calendar:

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

Aksesibilitas

Calendar mengikuti model navigasi grid dari pola Date Picker Dialog WAI-ARIA: React DayPicker merender bulan sebagai role="grid" dengan setiap hari sebagai tombol sungguhan, dan mengumumkan hari yang terpilih maupun yang dinonaktifkan kepada pembaca layar. Kedua varian backend berbagi perilaku ini karena membungkus pustaka yang sama.

Interaksi keyboard:

TombolAksi
Tab / Shift + TabMemindahkan fokus antara tombol navigasi dan grid hari
ArrowLeft / ArrowRightMemindahkan fokus ke hari sebelumnya / berikutnya
ArrowUp / ArrowDownMemindahkan fokus ke hari yang sama pada minggu sebelumnya / berikutnya
Home / EndMemindahkan fokus ke hari pertama / terakhir dalam minggu
PageUp / PageDownBerpindah ke bulan sebelumnya / berikutnya
Shift + PageUp / Shift + PageDownBerpindah ke tahun sebelumnya / berikutnya
Space / EnterMemilih hari yang difokus

Referensi API

Lihat dokumentasi React DayPicker untuk informasi lebih lanjut tentang komponen Calendar.

Catatan Perubahan

Dukungan RTL

Jika Anda memutakhirkan dari versi sebelumnya komponen Calendar, Anda perlu menerapkan pembaruan berikut untuk menambahkan dukungan locale:

Impor tipe Locale.

Tambahkan Locale ke import Anda dari react-day-picker:

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

Tambahkan prop locale ke komponen Calendar.

Tambahkan prop locale ke prop komponen:

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

Oper locale ke DayPicker.

Oper prop locale ke komponen 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,
      }}

Perbarui CalendarDayButton agar menerima locale.

Perbarui tanda tangan komponen CalendarDayButton dan oper locale:

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

Perbarui pemformatan tanggal di CalendarDayButton.

Gunakan locale?.code dalam pemformatan tanggal:

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

Oper locale ke komponen DayButton.

Perbarui penggunaan komponen DayButton untuk mengoper prop locale:

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

Perbarui kelas CSS yang sadar RTL.

Ganti kelas terarah dengan properti logis untuk dukungan RTL yang lebih baik:

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

Setelah menerapkan perubahan ini, Anda bisa menggunakan prop locale untuk menyediakan pemformatan khusus locale:

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