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
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
October 2026
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
pnpm dlx shadcn@latest add @paragon/calendarBrings 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
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
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| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
ุฃูุชูุจุฑ ูขู ูขูฆ
ุฑุจูุน ุงูุขุฎุฑ โ ุฌู ุงุฏู ุงูุฃููู ูกูคูคูจ ูู| ุญ | ู | ุซ | ุฑ | ุฎ | ุฌ | ุณ |
|---|---|---|---|---|---|---|
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 }