Search

Jump to any page.

Forms

Calendar

Month grid for a date or a range, in any locale, with the week starting where the region starts it and optional Hijri days.

September 2026

MonTueWedThuFriSatSun

October 2026

MonTueWedThuFriSatSun
pnpm dlx shadcn@latest add @paragon/calendar
Brings in@paragon/buttonlucide-reactcn

One date

mode="single" with min, max and isDateDisabled. With locale="en-SA" the week starts on Sunday; Fridays and Saturdays are off here.

October 2026

SunMonTueWedThuFriSat

Review with the Billing owner on Tuesday 27 October

Hijri and Arabic

hijri sets the Umm al-Qura day under each Gregorian day and the Hijri months in the caption. In ar-SA the digits, names and week follow Saudi Arabia.

October 2026

Rabiสป IIโ€‰โ€“โ€‰Jumada I 1448 AH
MonTueWedThuFriSatSun

ุฃูƒุชูˆุจุฑ ูขู ูขูฆ

ุฑุจูŠุน ุงู„ุขุฎุฑ โ€“ ุฌู…ุงุฏู‰ ุงู„ุฃูˆู„ู‰ ูกูคูคูจ ู‡ู€
ุญู†ุซุฑุฎุฌุณ
registry/paragon/ui/calendar.tsx
"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "cn"

import { Button } from "@/registry/paragon/ui/button"

type DateRange = { from: Date; to: Date | null }

type CalendarSharedProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  /** Sets digits, month and weekday names, and the first day of the week. */
  locale?: string
  /** Months shown side by side. */
  numberOfMonths?: number
  /** The first month shown. Defaults to the selection, then this month. */
  defaultMonth?: Date
  /** 0 is Sunday. Defaults to the locale's region: Sunday in Saudi Arabia, Saturday in Kuwait, Monday in the UAE. */
  weekStartsOn?: number
  min?: Date
  max?: Date
  isDateDisabled?: (date: Date) => boolean
  /** Show the Hijri (Umm al-Qura) day under each Gregorian day, and the Hijri months in the caption. */
  hijri?: boolean
  previousLabel?: string
  nextLabel?: string
}

type CalendarSingleProps = {
  mode?: "single"
  value?: Date | null
  defaultValue?: Date | null
  onValueChange?: (value: Date) => void
}

type CalendarRangeProps = {
  mode: "range"
  value?: DateRange | null
  defaultValue?: DateRange | null
  onValueChange?: (value: DateRange) => void
}

type CalendarProps = CalendarSharedProps & (CalendarSingleProps | CalendarRangeProps)

// The first day of the week by region, from CLDR. Inlined rather than read
// from Intl.Locale#getWeekInfo, which some browsers lack: the server and the
// browser must agree, or the weekday row changes on hydration.
const SUNDAY_FIRST = new Set("SA YE US CA MX BR JP KR IL IN PH TW HK PK BD ID SG ZA".split(" "))
const SATURDAY_FIRST = new Set("KW QA BH OM IQ JO SY EG LY SD DZ IR AF".split(" "))

function weekStartOf(locale: string) {
  const region = new Intl.Locale(locale).maximize().region ?? ""
  return SUNDAY_FIRST.has(region) ? 0 : SATURDAY_FIRST.has(region) ? 6 : 1
}

const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate())
const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1)
const addDays = (date: Date, days: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + days)
const addMonths = (date: Date, months: number) => new Date(date.getFullYear(), date.getMonth() + months, 1)
const daysIn = (month: Date) => new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate()
const dayKey = (date: Date) => date.getFullYear() * 10000 + date.getMonth() * 100 + date.getDate()
const monthKey = (date: Date) => date.getFullYear() * 100 + date.getMonth()
const isoDay = (date: Date) =>
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`

/** The same day of the month, `months` away, clamped to that month's length. */
function shiftMonth(date: Date, months: number) {
  const month = addMonths(date, months)
  return new Date(month.getFullYear(), month.getMonth(), Math.min(date.getDate(), daysIn(month)))
}

function toRange(value: Date | DateRange | null | undefined): DateRange | null {
  if (value == null) return null
  if (value instanceof Date) return { from: startOfDay(value), to: startOfDay(value) }
  return { from: startOfDay(value.from), to: value.to ? startOfDay(value.to) : null }
}

function ordered(a: Date, b: Date): DateRange {
  return dayKey(a) <= dayKey(b) ? { from: a, to: b } : { from: b, to: a }
}

// Dates are formatted with Intl, and the server's ICU data can differ from the
// browser's in small ways, such as the spaces around a range's dash or a Hijri
// month's spelling. Each formatted node takes suppressHydrationWarning, as React
// advises for locale-formatted dates.

// Today is read in the browser only. The server's clock and time zone are not
// the reader's, so it renders no today mark rather than the wrong one.
const subscribeToNothing = () => () => {}
function useToday() {
  return React.useSyncExternalStore(subscribeToNothing, () => dayKey(new Date()), () => null)
}

/**
 * A month grid for one date or a range. A range is drawn as one band with ink
 * squares at its ends, the mark's square ends; while the second end is being
 * chosen the band follows the pointer and keyboard focus. Arrow keys move by
 * day and week (left and right follow reading direction), Home and End to the
 * ends of the week, Page Up and Page Down by month, with Shift by year.
 */
function Calendar(props: CalendarProps) {
  const {
    mode = "single",
    value,
    defaultValue,
    onValueChange,
    locale = "en-GB",
    numberOfMonths = 1,
    defaultMonth,
    weekStartsOn,
    min,
    max,
    isDateDisabled,
    hijri = false,
    previousLabel = "Previous month",
    nextLabel = "Next month",
    className,
    ...rest
  } = props as CalendarSharedProps & {
    mode?: "single" | "range"
    value?: Date | DateRange | null
    defaultValue?: Date | DateRange | null
    onValueChange?: (value: Date | DateRange) => void
  }

  const [uncontrolled, setUncontrolled] = React.useState(() => toRange(defaultValue))
  const selected = value !== undefined ? toRange(value) : uncontrolled
  const [visible, setVisible] = React.useState(() => startOfMonth(defaultMonth ?? selected?.from ?? new Date()))
  const [focused, setFocused] = React.useState(() => selected?.from ?? startOfMonth(defaultMonth ?? new Date()))
  const [hovered, setHovered] = React.useState<Date | null>(null)
  const [travel, setTravel] = React.useState<"next" | "prev" | null>(null)
  const focusRequested = React.useRef(false)
  const rootRef = React.useRef<HTMLDivElement>(null)
  const today = useToday()
  const id = React.useId()

  const weekStart = weekStartsOn ?? weekStartOf(locale)
  const lastVisible = addMonths(visible, numberOfMonths - 1)
  const minKey = min ? dayKey(min) : -Infinity
  const maxKey = max ? dayKey(max) : Infinity

  const formats = React.useMemo(
    () => ({
      number: new Intl.NumberFormat(locale, { useGrouping: false }),
      caption: new Intl.DateTimeFormat(locale, { calendar: "gregory", month: "long", year: "numeric" }),
      day: new Intl.DateTimeFormat(locale, { calendar: "gregory", weekday: "long", day: "numeric", month: "long", year: "numeric" }),
      weekdayShort: new Intl.DateTimeFormat(locale, { weekday: "short" }),
      weekdayNarrow: new Intl.DateTimeFormat(locale, { weekday: "narrow" }),
      weekdayLong: new Intl.DateTimeFormat(locale, { weekday: "long" }),
      hijriDay: new Intl.DateTimeFormat(locale, { calendar: "islamic-umalqura", day: "numeric" }),
      hijriCaption: new Intl.DateTimeFormat(locale, { calendar: "islamic-umalqura", month: "long", year: "numeric" }),
    }),
    [locale]
  )

  // A short weekday name where it fits a cell ("Sun"), the narrow one where it doesn't ("ุงู„ุฃุญุฏ" becomes "ุญ").
  const weekdays = Array.from({ length: 7 }, (_, index) => {
    const date = addDays(new Date(2026, 9, 18), (weekStart + index) % 7) // 18 Oct 2026 is a Sunday.
    const short = formats.weekdayShort.format(date)
    return { short: short.length > 3 ? formats.weekdayNarrow.format(date) : short, long: formats.weekdayLong.format(date) }
  })

  const isDisabled = (date: Date) => {
    const key = dayKey(date)
    return key < minKey || key > maxKey || (isDateDisabled?.(date) ?? false)
  }

  React.useEffect(() => {
    if (!focusRequested.current) return
    focusRequested.current = false
    rootRef.current?.querySelector<HTMLButtonElement>(`[data-day="${isoDay(focused)}"]`)?.focus()
  })

  function show(month: Date) {
    setTravel(monthKey(month) > monthKey(visible) ? "next" : "prev")
    setVisible(month)
  }

  function moveFocus(date: Date) {
    const key = dayKey(date)
    const next = key < minKey && min ? startOfDay(min) : key > maxKey && max ? startOfDay(max) : date
    if (monthKey(next) < monthKey(visible)) show(startOfMonth(next))
    else if (monthKey(next) > monthKey(lastVisible)) show(addMonths(startOfMonth(next), -(numberOfMonths - 1)))
    setFocused(next)
    if (mode === "range" && selected && !selected.to) setHovered(next)
    focusRequested.current = true
  }

  function select(date: Date) {
    let next: DateRange
    if (mode === "single") next = { from: date, to: date }
    else if (!selected || selected.to) next = { from: date, to: null }
    else next = ordered(selected.from, date)
    setFocused(date)
    setHovered(null)
    if (value === undefined) setUncontrolled(next)
    onValueChange?.(mode === "single" ? date : next)
  }

  function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    if (!(event.target instanceof HTMLElement) || !event.target.dataset.day) return
    const rtl = getComputedStyle(event.currentTarget).direction === "rtl"
    const intoWeek = (focused.getDay() - weekStart + 7) % 7
    const next = {
      ArrowLeft: () => addDays(focused, rtl ? 1 : -1),
      ArrowRight: () => addDays(focused, rtl ? -1 : 1),
      ArrowUp: () => addDays(focused, -7),
      ArrowDown: () => addDays(focused, 7),
      Home: () => addDays(focused, -intoWeek),
      End: () => addDays(focused, 6 - intoWeek),
      PageUp: () => shiftMonth(focused, event.shiftKey ? -12 : -1),
      PageDown: () => shiftMonth(focused, event.shiftKey ? 12 : 1),
    }[event.key]
    if (!next) return
    event.preventDefault()
    moveFocus(next())
  }

  // While the second end is being chosen, the band runs to wherever the pointer or focus is.
  const previewing = mode === "range" && selected != null && selected.to == null && hovered != null
  const shown = previewing ? ordered(selected.from, hovered) : selected && { from: selected.from, to: selected.to ?? selected.from }
  const fromKey = shown ? dayKey(shown.from) : null
  const toKey = shown?.to ? dayKey(shown.to) : null
  // The one day in view that takes Tab: the focused day, or the first of the first month.
  const tabbable =
    monthKey(focused) >= monthKey(visible) && monthKey(focused) <= monthKey(lastVisible) ? dayKey(focused) : dayKey(visible)

  return (
    <div
      {...rest}
      ref={rootRef}
      data-slot="calendar"
      data-mode={mode}
      className={cn("flex w-fit flex-col gap-6 sm:flex-row sm:gap-8", className)}
      onKeyDown={(event) => {
        rest.onKeyDown?.(event)
        onKeyDown(event)
      }}
      onPointerLeave={(event) => {
        rest.onPointerLeave?.(event)
        setHovered(null)
      }}
    >
      {Array.from({ length: numberOfMonths }, (_, index) => {
        const month = addMonths(visible, index)
        const offset = (month.getDay() - weekStart + 7) % 7
        const length = daysIn(month)
        const captionId = `${id}-${index}`
        return (
          <div key={index} data-slot="calendar-month" className="flex flex-col gap-3">
            <div className="grid grid-cols-[2.25rem_1fr_2.25rem] items-center">
              {index === 0 ? (
                <Button
                  variant="ghost"
                  size="icon-sm"
                  aria-label={previousLabel}
                  disabled={min != null && monthKey(addMonths(visible, -1)) < monthKey(min)}
                  onClick={() => {
                    show(addMonths(visible, -1))
                    setFocused(shiftMonth(focused, -1))
                  }}
                >
                  <ChevronLeft className="rtl:-scale-x-100" />
                </Button>
              ) : (
                <span />
              )}
              <div className="flex flex-col items-center text-center">
                <h2 id={captionId} aria-live="polite" className="text-sm font-medium tracking-[-0.01em]" suppressHydrationWarning>
                  {formats.caption.format(month)}
                </h2>
                {hijri ? (
                  <span className="text-xs text-muted-foreground" suppressHydrationWarning>
                    {formats.hijriCaption.formatRange(month, new Date(month.getFullYear(), month.getMonth(), length))}
                  </span>
                ) : null}
              </div>
              {index === numberOfMonths - 1 ? (
                <Button
                  variant="ghost"
                  size="icon-sm"
                  aria-label={nextLabel}
                  className="justify-self-end"
                  disabled={max != null && monthKey(addMonths(lastVisible, 1)) > monthKey(max)}
                  onClick={() => {
                    show(addMonths(visible, 1))
                    setFocused(shiftMonth(focused, 1))
                  }}
                >
                  <ChevronRight className="rtl:-scale-x-100" />
                </Button>
              ) : (
                <span />
              )}
            </div>
            <table role="grid" aria-labelledby={captionId} className="border-collapse">
              <thead>
                <tr>
                  {weekdays.map((weekday) => (
                    <th
                      key={weekday.long}
                      scope="col"
                      abbr={weekday.long}
                      className="h-8 w-10 p-0 text-center text-xs font-medium text-muted-foreground"
                      suppressHydrationWarning
                    >
                      {weekday.short}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody
                key={monthKey(month)}
                data-travel={travel ?? undefined}
                className="duration-(--duration-base) ease-paragon data-travel:animate-in data-travel:fade-in-0 data-[travel=next]:slide-in-from-end-2 data-[travel=prev]:slide-in-from-start-2 motion-reduce:animate-none"
              >
                {/* Six rows in every month, so the grid never changes height between months. */}
                {Array.from({ length: 6 }, (_, row) => (
                  <tr key={row}>
                    {Array.from({ length: 7 }, (_, column) => {
                      const day = row * 7 + column - offset + 1
                      if (day < 1 || day > length) return <td key={column} className="p-0" />
                      const date = new Date(month.getFullYear(), month.getMonth(), day)
                      const key = dayKey(date)
                      const start = key === fromKey
                      const end = key === toKey
                      const spans = fromKey != null && toKey != null && fromKey !== toKey
                      const inside = spans && key > fromKey && key < toKey
                      const disabled = isDisabled(date)
                      return (
                        <td
                          key={column}
                          role="gridcell"
                          aria-selected={start || end || inside || undefined}
                          className="relative p-0 py-[3px]"
                        >
                          {inside || (spans && (start || end)) ? (
                            <span
                              aria-hidden="true"
                              data-preview={previewing || undefined}
                              className={cn(
                                "absolute inset-y-[3px] bg-foreground/7 data-preview:bg-foreground/4",
                                inside && "inset-x-0",
                                start && "start-1/2 end-0",
                                end && "start-0 end-1/2",
                                // The band rounds off where a week or a month breaks it.
                                (column === 0 || day === 1) && !start && "rounded-s-[0.625rem]",
                                (column === 6 || day === length) && !end && "rounded-e-[0.625rem]"
                              )}
                            />
                          ) : null}
                          <button
                            type="button"
                            data-day={isoDay(date)}
                            data-today={key === today || undefined}
                            data-endpoint={start || end || undefined}
                            data-preview={previewing || undefined}
                            tabIndex={key === tabbable ? 0 : -1}
                            disabled={disabled}
                            aria-label={formats.day.format(date)}
                            aria-current={key === today ? "date" : undefined}
                            suppressHydrationWarning
                            onClick={() => select(date)}
                            onFocus={() => {
                              setFocused(date)
                              if (mode === "range" && selected && !selected.to) setHovered(date)
                            }}
                            onPointerEnter={() => {
                              if (mode === "range" && selected && !selected.to) setHovered(date)
                            }}
                            className={cn(
                              "group/day relative flex w-10 flex-col items-center justify-center rounded-[0.625rem] text-sm tabular-nums outline-none",
                              "transition-[background-color,color,scale] duration-(--duration-fast) ease-paragon active:scale-[0.94] motion-reduce:active:scale-100",
                              "hover:bg-foreground/6 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring focus-visible:outline-solid",
                              "disabled:pointer-events-none disabled:opacity-35",
                              "data-endpoint:bg-foreground data-endpoint:font-medium data-endpoint:text-background data-endpoint:data-preview:bg-foreground/80",
                              // Today: a small square in the corner, the mark's square end.
                              "data-today:after:absolute data-today:after:end-1.5 data-today:after:top-1.5 data-today:after:size-1 data-today:after:bg-brand data-endpoint:data-today:after:bg-current",
                              hijri ? "h-11 gap-0.5" : "h-10"
                            )}
                          >
                            <span className="leading-none" suppressHydrationWarning>
                              {formats.number.format(day)}
                            </span>
                            {hijri ? (
                              <span
                                className="text-[0.625rem] leading-none text-muted-foreground group-data-endpoint/day:text-background/70"
                                suppressHydrationWarning
                              >
                                {formats.hijriDay.format(date)}
                              </span>
                            ) : null}
                          </button>
                        </td>
                      )
                    })}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )
      })}
    </div>
  )
}

export { Calendar, type CalendarProps, type DateRange }