Calendar
A month grid on react-day-picker for picking a day or a range.
Vibra passes react-day-picker's focus ref through to the day button, so the arrow keys move real focus from day to day — shadcn's copy creates the ref and never attaches it — and marks that day once, with the kit's 2px outline. Its parts are the same components from one render to the next, so a press never rebuilds the grid under the pointer. A range's rounded ends and the band between them sit on the inline axis, so a right-to-left range mirrors. A day's data-day and the month dropdown are formatted in the calendar's locale (en-US unless you pass one) rather than the runtime's, so a server render hydrates in any browser. Pass timeZone="UTC" with midnight-UTC dates so a day is the same day for every reader, and a fixed today wherever a demo or a test must not move with the clock. A disabled day stays in the grid and the arrow keys pass it by; put its reason in its name through labels.labelDayButton. In multiple mode max starts over from the pressed day once the limit is reached — to refuse the extra day instead, check the length in onSelect. The file also exports PickerCalendarProps and pickerDisabled, the calendar props DatePicker and DateRangePicker hand on to the grid in their popovers (everything but the mode, the selection, the months and the focus) and the rule that adds a caller's disabled days to min and max. Weekday and week-number labels use the 13px label register, and selected days fill in the accent through --primary.
Install
npx shadcn@latest add @vibra/calendarNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Calendar } from "@/components/ui/calendar"
/** A fixed day, so the example reads the same on the server, in a test, and in a screenshot. */
const RENEWAL = new Date("2026-09-18T00:00:00.000Z")
// Days are UTC days, so every reader sees the same 18th, and "today" is the
// sample data's reference date rather than the clock.
export default function CalendarDemo() {
const [date, setDate] = React.useState<Date | undefined>(RENEWAL)
return (
<Calendar
mode="single"
timeZone="UTC"
today={REFERENCE_DATE}
selected={date}
onSelect={setDate}
defaultMonth={RENEWAL}
className="rounded-lg ring-1 ring-border"
/>
)
}A stay, counted in nights
mode="range" with resetOnSelect: the night you arrive, then the day you leave, and a status line that counts the nights.
"use client"
import * as React from "react"
import { labelDayButton, type DateRange } from "react-day-picker"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Calendar } from "@/components/ui/calendar"
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
const DATE = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short", timeZone: "UTC" })
const nightsBetween = (from: Date, to: Date) => Math.round((to.getTime() - from.getTime()) / 86_400_000)
// A stay picked as a range: the first press is the night you arrive, the
// second the morning you leave — and a press after that starts a new stay
// (resetOnSelect). The line beside it counts the nights. Nights before today
// are dimmed, and each one's name says why.
export default function CalendarBooking() {
const [stay, setStay] = React.useState<DateRange | undefined>({ from: day("2026-09-12"), to: day("2026-09-15") })
const nights = stay?.from && stay.to ? nightsBetween(stay.from, stay.to) : 0
return (
<div className="flex flex-col items-center gap-1 sm:flex-row sm:items-start sm:gap-6">
<Calendar
mode="range"
timeZone="UTC"
today={REFERENCE_DATE}
defaultMonth={day("2026-09-01")}
selected={stay}
onSelect={setStay}
resetOnSelect
disabled={{ before: REFERENCE_DATE }}
excludeDisabled
labels={{
labelDayButton: (date, modifiers, options, dateLib) =>
`${labelDayButton(date, modifiers, options, dateLib)}${modifiers.disabled ? ", unavailable: before today" : ""}`,
}}
className="rounded-lg ring-1 ring-border"
/>
<div className="flex flex-col gap-1 text-center sm:max-w-56 sm:pt-2 sm:text-start">
<p role="status" aria-live="polite" className="text-sm font-medium tabular-nums">
{stay?.from && stay.to && nights > 0
? `${nights} ${nights === 1 ? "night" : "nights"}, ${DATE.format(stay.from)} to ${DATE.format(stay.to)}`
: stay?.from
? "Now the day you leave."
: "Choose the night you arrive."}
</p>
<p className="hidden text-xs text-balance text-muted-foreground sm:block">Harbour View, Dublin. Check-in from 3 PM.</p>
</div>
</div>
)
}Two months for a longer range
A campaign's run across the month end: two months side by side where there is room, one on a phone.
"use client"
import * as React from "react"
import { labelDayButton, type DateRange } from "react-day-picker"
import { useMediaQuery } from "@/hooks/use-media-query"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Calendar } from "@/components/ui/calendar"
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
const DATE = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short", timeZone: "UTC" })
/** "Runs 26 days, Sep 21 to Oct 16" — both ends count. */
function describe(run?: DateRange) {
if (!run?.from || !run.to) return "Choose the first day of the run."
const days = Math.round((run.to.getTime() - run.from.getTime()) / 86_400_000) + 1
if (days === 1) return `Runs 1 day, ${DATE.format(run.from)}`
return `Runs ${days} days, ${DATE.format(run.from)} to ${DATE.format(run.to)}`
}
// A campaign's run crosses the month end, so both months show side by side
// where there is room and one at a time on a phone — without the neighbours'
// days, which would put the last week in both grids. A run can't start in the
// past: those days are dimmed, and their names say so.
export default function CalendarTwoMonths() {
const wide = useMediaQuery("(min-width: 768px)")
const [run, setRun] = React.useState<DateRange | undefined>({ from: day("2026-09-21"), to: day("2026-10-16") })
return (
<div className="flex flex-col items-center gap-2 md:flex-row md:items-start md:gap-6">
<Calendar
mode="range"
timeZone="UTC"
today={REFERENCE_DATE}
numberOfMonths={wide ? 2 : 1}
showOutsideDays={false}
defaultMonth={day("2026-09-01")}
startMonth={day("2026-09-01")}
selected={run}
onSelect={setRun}
disabled={{ before: REFERENCE_DATE }}
labels={{
labelDayButton: (date, modifiers, options, dateLib) =>
`${labelDayButton(date, modifiers, options, dateLib)}${modifiers.disabled ? ", unavailable: in the past" : ""}`,
}}
className="rounded-lg ring-1 ring-border"
/>
<div className="flex flex-col gap-1 text-center md:max-w-40 md:pt-2 md:text-start">
<p className="hidden text-sm font-medium md:block">Win-Back Campaign</p>
<p role="status" aria-live="polite" className="text-xs text-balance text-muted-foreground tabular-nums">
{describe(run)}
</p>
</div>
</div>
)
}Several days, up to a limit
mode="multiple": days off named and counted against five, and a sixth refused with the reason.
"use client"
import * as React from "react"
import { labelDayButton } from "react-day-picker"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Calendar } from "@/components/ui/calendar"
const MAX = 5
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
const DAY = new Intl.DateTimeFormat("en-US", { day: "numeric", timeZone: "UTC" })
const DATE = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short", timeZone: "UTC" })
/** "Sep 14, 15 and 16" — in order, the month named where it changes. */
function listOf(days: Date[]) {
const sorted = [...days].sort((a, b) => a.getTime() - b.getTime())
const parts = sorted.map((date, i) =>
i > 0 && date.getUTCMonth() === sorted[i - 1].getUTCMonth() ? DAY.format(date) : DATE.format(date)
)
return parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}` : parts[0]
}
// Days off picked one by one, up to five for a single request. The line
// beside the calendar names them and counts them against the limit. A sixth
// is refused, and the line says why: max would start over from the pressed
// day instead, and throw the five away.
export default function CalendarMultiple() {
const [days, setDays] = React.useState<Date[]>([day("2026-09-14"), day("2026-09-15"), day("2026-09-16")])
const [refused, setRefused] = React.useState(false)
const count = days.length
return (
<div className="flex flex-col items-center gap-1 sm:flex-row sm:items-start sm:gap-6">
<Calendar
mode="multiple"
timeZone="UTC"
today={REFERENCE_DATE}
defaultMonth={day("2026-09-01")}
selected={days}
onSelect={(next = []) => {
setRefused(next.length > MAX)
if (next.length <= MAX) setDays(next)
}}
disabled={{ dayOfWeek: [0, 6] }}
labels={{
labelDayButton: (date, modifiers, options, dateLib) =>
`${labelDayButton(date, modifiers, options, dateLib)}${modifiers.disabled ? ", unavailable: weekend" : ""}`,
}}
className="rounded-lg ring-1 ring-border"
/>
<p role="status" aria-live="polite" className="text-center text-xs text-balance text-muted-foreground tabular-nums sm:max-w-44 sm:pt-2 sm:text-start">
{refused
? "Five is the limit. Remove a day first."
: count === 0
? "Choose up to five weekdays off."
: `${count} of ${MAX}: ${listOf(days)}`}
</p>
</div>
)
}Whole weeks, with their numbers
ISO week numbers down the side; a press on any day plans its week, Monday to Sunday.
"use client"
import * as React from "react"
import type { DateRange } from "react-day-picker"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Calendar } from "@/components/ui/calendar"
const DAY = 86_400_000
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
const DATE = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short", timeZone: "UTC" })
/** The ISO week a day falls in, Monday to Sunday. */
function weekOf(date: Date): DateRange {
const monday = new Date(date.getTime() - ((date.getUTCDay() + 6) % 7) * DAY)
return { from: monday, to: new Date(monday.getTime() + 6 * DAY) }
}
/** ISO 8601 week number: the week is numbered by the year its Thursday falls in. */
function isoWeek(date: Date) {
const thursday = new Date(date.getTime() + (4 - (date.getUTCDay() || 7)) * DAY)
const yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1)
return Math.ceil(((thursday.getTime() - yearStart) / DAY + 1) / 7)
}
// Sprints run Monday to Sunday, so a press on any day plans its whole week:
// the week numbers down the side are ISO weeks, which is what the sprint
// board calls them, and the line under it names the sprint.
export default function CalendarWeekNumbers() {
const [week, setWeek] = React.useState<DateRange>(weekOf(day("2026-09-16")))
return (
<div className="flex flex-col items-center gap-1 sm:flex-row sm:items-start sm:gap-6">
<Calendar
mode="range"
timeZone="UTC"
today={REFERENCE_DATE}
ISOWeek
showWeekNumber
defaultMonth={day("2026-09-01")}
selected={week}
onSelect={(_, pressed) => setWeek(weekOf(pressed))}
className="rounded-lg p-1 ring-1 ring-border"
/>
<p role="status" aria-live="polite" className="text-center text-xs text-balance text-muted-foreground tabular-nums sm:max-w-44 sm:pt-2 sm:text-start">
Sprint for week {isoWeek(week.from!)}: {DATE.format(week.from!)} to {DATE.format(week.to!)}
</p>
</div>
)
}Jump by month and year
captionLayout="dropdown" for a date years back, and a button that brings the view to this month.
"use client"
import * as React from "react"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
const DATE = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" })
const THIS_MONTH = day("2026-09-01")
// A start date years back is two dropdowns away rather than dozens of
// presses, and a button brings the view back to this month. The button
// says what it does and stays put, so the focus never jumps.
export default function CalendarDropdowns() {
const [month, setMonth] = React.useState(day("2021-03-01"))
const [date, setDate] = React.useState<Date | undefined>(day("2021-03-15"))
return (
<div className="flex flex-col items-center gap-1 sm:flex-row sm:items-start sm:gap-6">
<Calendar
mode="single"
timeZone="UTC"
today={REFERENCE_DATE}
captionLayout="dropdown"
startMonth={day("2015-01-01")}
endMonth={day("2026-12-01")}
month={month}
onMonthChange={setMonth}
selected={date}
onSelect={setDate}
className="rounded-lg p-1 ring-1 ring-border"
/>
<div className="flex items-center gap-2 sm:flex-col sm:items-start sm:pt-2">
<p aria-live="polite" className="text-xs text-muted-foreground tabular-nums">
{date ? `Joined ${DATE.format(date)}` : "No start date"}
</p>
<Button variant="outline" size="xs" onClick={() => setMonth(THIS_MONTH)}>
Show this month
</Button>
</div>
</div>
)
}Unavailable days, with the reason
Weekends, a holiday and the notice period dimmed; each day is named with its reason and the grid points at the rules.
"use client"
import * as React from "react"
import { labelDayButton } from "react-day-picker"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Calendar } from "@/components/ui/calendar"
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
const LABOR_DAY = day("2026-09-07")
const DATE = new Intl.DateTimeFormat("en-US", { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" })
/** Why a day can't be booked, in the words its button is named with. */
function whyClosed(date: Date) {
if (date.getTime() <= REFERENCE_DATE.getTime()) return "demos need a day's notice"
if (date.getUTCDay() === 0 || date.getUTCDay() === 6) return "closed at weekends"
if (date.getTime() === LABOR_DAY.getTime()) return "closed for Labor Day"
return null
}
// Days a demo can't be booked on stay in the grid, dimmed — the arrow keys
// pass them by — and each is named with its reason. The line beside the
// calendar gives the rules once, for everyone, and the grid points at it.
export default function CalendarDisabled() {
const id = React.useId()
const [date, setDate] = React.useState<Date | undefined>()
return (
<div
role="group"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-rules`}
className="flex flex-col items-center gap-2 sm:flex-row sm:items-start sm:gap-6"
>
<Calendar
mode="single"
timeZone="UTC"
today={REFERENCE_DATE}
defaultMonth={day("2026-09-01")}
selected={date}
onSelect={setDate}
disabled={[{ before: day("2026-09-05") }, { dayOfWeek: [0, 6] }, LABOR_DAY]}
labels={{
labelDayButton: (value, modifiers, options, dateLib) => {
const reason = modifiers.disabled ? whyClosed(value) : null
return `${labelDayButton(value, modifiers, options, dateLib)}${reason ? `, unavailable: ${reason}` : ""}`
},
}}
className="rounded-lg ring-1 ring-border"
/>
<div className="flex flex-col gap-1 text-center sm:max-w-56 sm:pt-2 sm:text-start">
{/* On a phone the chosen day shows in the grid; the title stays for a screen reader. */}
<p id={`${id}-title`} className="sr-only text-sm font-medium sm:not-sr-only">
{date ? `Demo on ${DATE.format(date)}` : "Book a product demo"}
</p>
<p id={`${id}-rules`} className="text-xs text-balance text-muted-foreground">
Weekdays from tomorrow, except September 7.
</p>
</div>
</div>
)
}A rate on each night
A custom day button with the night's rate, which is part of the day's name; the lowest are said in words too.
"use client"
import * as React from "react"
import { labelDayButton } from "react-day-picker"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Calendar, CalendarDayButton } from "@/components/ui/calendar"
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
const DATE = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short", timeZone: "UTC" })
/** A night's rate: weekends dearer, midweek nights mid-month cheapest. UI vocabulary, not stored rates. */
function rateFor(date: Date) {
const weekday = date.getUTCDay()
if (weekday === 5 || weekday === 6) return 184
if (date.getUTCDate() >= 14 && date.getUTCDate() <= 17) return 119
return 146
}
/** The kit's day button with the night's rate under the date; outside and past days carry none. */
function RateDayButton({ children, ...props }: React.ComponentProps<typeof CalendarDayButton>) {
const rate = rateFor(props.day.date)
// Green at full strength on the page; a selected night's rate takes the fill's own colour.
const lowest = rate === 119 && !props.modifiers.selected
return (
<CalendarDayButton {...props}>
{children}
{props.modifiers.outside || props.modifiers.disabled ? null : (
<span className={lowest ? "font-medium text-success opacity-100!" : undefined}>{rate}</span>
)}
</CalendarDayButton>
)
}
// Each night carries its rate under the date, and the rate is part of the
// day's name, so a screen reader hears "$119 a night" with the date. The
// cheapest nights are said in words as well as marked.
export default function CalendarRates() {
const [night, setNight] = React.useState<Date | undefined>(day("2026-09-15"))
return (
<div className="flex flex-col items-center gap-1 sm:flex-row sm:items-start sm:gap-6">
<Calendar
mode="single"
timeZone="UTC"
today={REFERENCE_DATE}
defaultMonth={day("2026-09-01")}
selected={night}
onSelect={setNight}
disabled={{ before: REFERENCE_DATE }}
className="rounded-lg ring-1 ring-border [--cell-size:--spacing(8)]"
classNames={{ month: "flex w-full flex-col gap-2", week: "mt-1 flex w-full" }}
labels={{
labelDayButton: (date, modifiers, options, dateLib) => {
const rate = rateFor(date)
const base = labelDayButton(date, modifiers, options, dateLib)
return modifiers.disabled ? `${base}, unavailable: before today` : `${base}, $${rate} a night${rate === 119 ? ", lowest rate" : ""}`
},
}}
components={{ DayButton: RateDayButton }}
/>
<p role="status" aria-live="polite" className="text-center text-xs text-balance text-muted-foreground tabular-nums sm:max-w-44 sm:pt-2 sm:text-start">
{night ? `${DATE.format(night)}: $${rateFor(night)} a night.` : "Choose the night you arrive."}{" "}
<span className="font-medium text-success">Green</span> is the lowest rate.
</p>
</div>
)
}With a time
A day and a time from a Select beside it, read back as one line in UTC.
"use client"
import * as React from "react"
import { labelDayButton } from "react-day-picker"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Calendar } from "@/components/ui/calendar"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
const DATE = new Intl.DateTimeFormat("en-US", { weekday: "short", day: "numeric", month: "short", timeZone: "UTC" })
const TIMES = Array.from({ length: 20 }, (_, index) => {
const minutes = 8 * 60 + index * 30
const value = `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`
return { value, label: value }
})
// When a changelog post goes out: a day on the calendar and a time from a
// select beside it, both read as one sentence under them — in UTC, which the
// sentence says, so no reader wonders whose clock it is.
export default function CalendarTime() {
const id = React.useId()
const [date, setDate] = React.useState<Date | undefined>(day("2026-09-11"))
const [time, setTime] = React.useState<string | null>("09:30")
return (
<div className="flex flex-col items-center gap-1 sm:flex-row sm:items-start sm:gap-6">
<Calendar
mode="single"
timeZone="UTC"
today={REFERENCE_DATE}
defaultMonth={day("2026-09-01")}
selected={date}
onSelect={setDate}
disabled={{ before: REFERENCE_DATE }}
labels={{
labelDayButton: (value, modifiers, options, dateLib) =>
`${labelDayButton(value, modifiers, options, dateLib)}${modifiers.disabled ? ", unavailable: in the past" : ""}`,
}}
className="rounded-lg p-1 ring-1 ring-border"
/>
<div className="flex items-center gap-2 sm:flex-col sm:items-start sm:gap-1.5 sm:pt-2">
<span id={`${id}-time`} className="sr-only text-sm font-medium sm:not-sr-only">
Publish at
</span>
<Select items={TIMES} value={time} onValueChange={setTime}>
<SelectTrigger size="sm" aria-labelledby={`${id}-time`} className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMES.map((entry) => (
<SelectItem key={entry.value} value={entry.value}>
{entry.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground tabular-nums">
{date && time ? `${DATE.format(date)}, ${time} UTC` : "Choose a day."}
</p>
</div>
</div>
)
}A day, then a free slot
The slots follow the day; a booked one stays in the list and says so, and the chosen one is pressed on the brand tint.
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { labelDayButton } from "react-day-picker"
import { cn } from "@/lib/utils"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
const DATE = new Intl.DateTimeFormat("en-US", { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" })
const SLOTS = ["09:00", "09:30", "10:00", "10:30", "11:00", "13:30", "14:00", "14:30", "15:00", "15:30"]
/** Slots already booked on a given day: a rule over the date, not a stored list. */
const bookedOn = (date: Date) => new Set(SLOTS.filter((_, index) => (index * 7 + date.getUTCDate()) % 4 === 0))
// A day, then a time on it: the slots follow the day, a booked slot stays in
// the list and says so, and the chosen one is a pressed button on the brand
// tint with a check — never an outline, which is what focus looks like.
export default function CalendarSlots() {
const [date, setDate] = React.useState<Date | undefined>(day("2026-09-09"))
const [slot, setSlot] = React.useState<string | null>("10:30")
const booked = date ? bookedOn(date) : new Set<string>()
return (
<div className="flex flex-col items-center gap-1 sm:flex-row sm:items-start sm:gap-4">
<Calendar
mode="single"
timeZone="UTC"
today={REFERENCE_DATE}
defaultMonth={day("2026-09-01")}
selected={date}
onSelect={(next) => {
setDate(next)
setSlot(null)
}}
disabled={[{ before: day("2026-09-05") }, { dayOfWeek: [0, 6] }]}
labels={{
labelDayButton: (value, modifiers, options, dateLib) => {
const reason = value.getUTCDay() % 6 === 0 ? "no calls at weekends" : "no slots left"
return `${labelDayButton(value, modifiers, options, dateLib)}${modifiers.disabled ? `, unavailable: ${reason}` : ""}`
},
}}
className="rounded-lg p-1 ring-1 ring-border"
/>
<div role="group" aria-label={date ? `Times on ${DATE.format(date)}, in UTC` : "Times"} className="w-full max-w-72 sm:w-32">
<ul className="no-scrollbar flex gap-1 overflow-x-auto sm:max-h-60 sm:flex-col sm:overflow-x-visible sm:overflow-y-auto">
{SLOTS.map((time) => {
const taken = booked.has(time)
const chosen = slot === time
return (
<li key={time} className="shrink-0">
<Button
variant="ghost"
size="sm"
aria-pressed={chosen}
disabled={taken || !date}
focusableWhenDisabled
aria-label={taken ? `${time}, booked` : time}
onClick={() => setSlot(time)}
className={cn(
"w-full justify-between font-normal tabular-nums aria-pressed:bg-brand-muted aria-pressed:font-medium",
taken && "line-through"
)}
>
{time}
{chosen ? <CheckIcon aria-hidden="true" className="size-3.5" /> : null}
</Button>
</li>
)
})}
</ul>
</div>
</div>
)
}Range presets
Report ranges a press away, counted back from the reference date; the preset that matches is pressed.
"use client"
import * as React from "react"
import { labelDayButton, type DateRange } from "react-day-picker"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
const DAY = 86_400_000
const today = new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate()))
const back = (days: number) => new Date(today.getTime() - days * DAY)
const firstOfMonth = (offset: number) => new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + offset, 1))
const RANGE = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short", timeZone: "UTC" })
// Every preset is counted back from the reference date, never the clock.
const PRESETS = [
{ key: "7d", label: "Last 7 days", range: { from: back(6), to: today } },
{ key: "30d", label: "Last 30 days", range: { from: back(29), to: today } },
{ key: "last-month", label: "Last month", range: { from: firstOfMonth(-1), to: new Date(firstOfMonth(0).getTime() - DAY) } },
]
const same = (a?: DateRange, b?: DateRange) =>
a?.from?.getTime() === b?.from?.getTime() && a?.to?.getTime() === b?.to?.getTime()
// A report range with the common ones a press away. A preset moves the
// calendar to where it ends and shows as pressed while the range is its
// own; pick days by hand and none is. On a phone they run in a row above.
export default function CalendarPresets() {
const [range, setRange] = React.useState<DateRange | undefined>(PRESETS[0].range)
const [month, setMonth] = React.useState(firstOfMonth(0))
return (
<div className="flex flex-col items-center gap-1 sm:flex-row sm:items-start sm:gap-4">
<div role="group" aria-label="Report ranges" className="flex justify-center gap-1 sm:flex-col sm:justify-start">
{PRESETS.map((preset) => (
<Button
key={preset.key}
variant="ghost"
size="xs"
aria-pressed={same(range, preset.range)}
className="shrink-0 justify-start font-normal aria-pressed:bg-brand-muted aria-pressed:font-medium sm:h-8 sm:text-sm"
onClick={() => {
setRange(preset.range)
setMonth(firstOfMonth(preset.key === "last-month" ? -1 : 0))
}}
>
{preset.label}
</Button>
))}
<p role="status" aria-live="polite" className="sr-only text-xs text-muted-foreground tabular-nums sm:not-sr-only sm:px-2 sm:pt-2">
{range?.from && range.to ? `${RANGE.format(range.from)} – ${RANGE.format(range.to)}` : "Pick the first day"}
</p>
</div>
<Calendar
mode="range"
timeZone="UTC"
today={REFERENCE_DATE}
month={month}
onMonthChange={setMonth}
endMonth={firstOfMonth(0)}
selected={range}
onSelect={setRange}
disabled={{ after: today }}
labels={{
labelDayButton: (date, modifiers, options, dateLib) =>
`${labelDayButton(date, modifiers, options, dateLib)}${modifiers.disabled ? ", no data yet" : ""}`,
}}
className="rounded-lg p-1 ring-1 ring-border"
/>
</div>
)
}Right to left
The booking range in Arabic with dir="rtl": the week runs from the right, and the range's ends and chevrons follow it.
"use client"
import * as React from "react"
import type { DateRange } from "react-day-picker"
import { ar } from "react-day-picker/locale"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Calendar } from "@/components/ui/calendar"
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
const DATE = new Intl.DateTimeFormat("ar", { day: "numeric", month: "long", timeZone: "UTC", numberingSystem: "latn" })
// Arabic weekday names run long for a 28px column; the narrow form is one letter.
const WEEKDAY = new Intl.DateTimeFormat("ar", { weekday: "narrow", timeZone: "UTC" })
/** Nights in Arabic: one, two, a few (3–10) and many take different forms. */
function nights(count: number) {
if (count === 1) return "ليلة واحدة"
if (count === 2) return "ليلتان"
if (count <= 10) return `${count} ليالٍ`
return `${count} ليلة`
}
// The booking range in Arabic. The week runs from the right, the range's
// rounded ends and the band between them follow it, and the arrows keep
// pointing the way the months go — dir tells the calendar, which turns the
// chevrons and the arrow keys itself.
export default function CalendarRtl() {
const [stay, setStay] = React.useState<DateRange | undefined>({ from: day("2026-09-12"), to: day("2026-09-15") })
const count = stay?.from && stay.to ? Math.round((stay.to.getTime() - stay.from.getTime()) / 86_400_000) : 0
return (
<div dir="rtl" lang="ar" className="flex flex-col items-center gap-1 sm:flex-row sm:items-start sm:gap-6">
<Calendar
mode="range"
dir="rtl"
locale={ar}
timeZone="UTC"
today={REFERENCE_DATE}
defaultMonth={day("2026-09-01")}
selected={stay}
onSelect={setStay}
formatters={{ formatWeekdayName: (date) => WEEKDAY.format(date) }}
className="rounded-lg ring-1 ring-border"
/>
<p role="status" aria-live="polite" className="text-center text-xs text-muted-foreground sm:max-w-44 sm:pt-2 sm:text-start">
{stay?.from && stay.to && count > 0
? `${nights(count)}، من ${DATE.format(stay.from)} إلى ${DATE.format(stay.to)}`
: "اختر ليلة الوصول."}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| mode | "single" | "multiple" | "range" | — | What a press selects: one day, a set of days, or a range from a first day to a last. |
| selected / onSelect | Date | Date[] | DateRange | — | The selection, in the shape the mode gives it. onSelect also hands you the day that was pressed. |
| timeZone | string | — | The zone the grid's days are counted in. "UTC" with midnight-UTC dates keeps a day the same for every reader. |
| today | Date | the clock | The day marked as today, and the month shown when neither month nor defaultMonth is set. |
| disabled | Matcher | Matcher[] | — | Days that can't be picked: dimmed, native-disabled and passed by the arrow keys. |
| labels | Partial<Labels> | — | The names the grid reads out. Extend labelDayButton with a disabled day's reason, a rate or a count. |
| numberOfMonths | number | 1 | Months side by side; the arrows still page a month at a time. |
| month / onMonthChange | Date | — | The month in view, controlled — for a preset or a button that moves the view. |
| startMonth / endMonth | Date | — | The first and last months the arrows and the dropdowns reach. |
| resetOnSelect | boolean | false | Range mode: a press after a complete range starts a new one rather than moving an end. |
| excludeDisabled | boolean | false | Range mode: a range that would take in a disabled day starts over from the pressed day. |
| showWeekNumber / ISOWeek | boolean | false | A column of week numbers; ISOWeek counts them the ISO way, Monday to Sunday. |
| components | Partial<CustomComponents> | — | Swap a part: a DayButton that wraps CalendarDayButton adds a line under the date and keeps the kit's look. |
| classNames | Partial<ClassNames> | — | Classes per part. A part you name takes your classes in place of the kit's — a tighter week row for a taller day. |
| formatters | Partial<Formatters> | — | How captions and weekday names are written; formatWeekdayName can narrow them to fit a column. |
| locale / dir | Locale / "ltr" | "rtl" | — | Month and weekday names in a language, and the direction the week runs; the arrows and the range's ends follow dir. |
| buttonVariant | ButtonProps["variant"] | "ghost" | The Button variant of the previous and next month arrows. |
| captionLayout | "label" | "dropdown" | "dropdown-months" | "dropdown-years" | "label" | A plain month label, or dropdowns for jumping by month and year. |
| showOutsideDays | boolean | true | Shows the neighbouring months' days in the first and last weeks. |
Dependencies
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
DayPicker,
getDefaultClassNames,
useDayPicker,
type CustomComponents,
type DayButton,
type Locale,
type Matcher,
type PropsBase,
} from "react-day-picker"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
/**
* react-day-picker writes its own labels in en-US unless it is given a locale.
* The two strings this file formats itself — a day's data-day and the month
* dropdown — follow that default rather than the runtime's, so a calendar
* rendered on a server in one locale hydrates in a browser set to another.
*/
const DEFAULT_LOCALE_CODE = "en-US"
function Calendar({
className,
classNames,
showOutsideDays = 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}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] 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) =>
date.toLocaleString(locale?.code ?? DEFAULT_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) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 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(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "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-1 rounded-(--cell-radius) text-label 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-label text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-s-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-s-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:end-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-e-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:start-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted 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,
}}
// The parts are module-level functions, the same from one render to the
// next: written inline, each render handed react-day-picker new
// component types and React rebuilt the whole grid under the pointer.
components={{
Root: CalendarRoot,
Chevron: CalendarChevron,
DayButton: CalendarDayButton,
WeekNumber: CalendarWeekNumber,
...components,
}}
{...props}
/>
)
}
function CalendarRoot({
className,
rootRef,
...props
}: React.ComponentProps<CustomComponents["Root"]>) {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
}
function CalendarChevron({
className,
orientation,
...props
}: React.ComponentProps<CustomComponents["Chevron"]>) {
if (orientation === "left") {
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />
}
if (orientation === "right") {
return <ChevronRightIcon className={cn("size-4", className)} {...props} />
}
return <ChevronDownIcon className={cn("size-4", className)} {...props} />
}
// A row header, as react-day-picker's own is: it passes `scope="row"`, which
// only a th may carry, and the week object itself, which is data rather than
// an attribute — spread onto a td it wrote week="[object Object]".
function CalendarWeekNumber({ children, ...props }: React.ComponentProps<CustomComponents["WeekNumber"]>) {
const cell: Partial<typeof props> = { ...props }
delete cell.week
return (
<th {...cell}>
<div className="flex size-(--cell-size) items-center justify-center text-center font-normal">
{children}
</div>
</th>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
// The calendar's own locale unless one is passed, so a custom DayButton
// that wraps this one keeps the day's data-day in the calendar's language.
const { dayPickerProps } = useDayPicker()
const localeCode = (locale ?? dayPickerProps.locale)?.code ?? DEFAULT_LOCALE_CODE
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
// COUPLED TO upstream shadcn calendar: the focus ref must reach the Button for react-day-picker's keyboard day navigation; re-check after a CLI refresh.
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(localeCode)}
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 aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground 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}
/>
)
}
/**
* What a picker hands on to the calendar in its popover: `today`, `labels`,
* `modifiers`, `footer`, `weekStartsOn` and the rest — everything but what the
* picker owns (the mode, the selection, the months it opens on and can reach,
* the focus). A `disabled` given here is added to the days min and max already
* rule out. The picker's days are the runtime's own calendar days, so the
* calendar takes no time zone of its own.
*/
export type PickerCalendarProps = Omit<
PropsBase,
| "mode"
| "required"
| "month"
| "defaultMonth"
| "onMonthChange"
| "startMonth"
| "endMonth"
| "autoFocus"
| "timeZone"
| "noonSafe"
>
/** The days a picker rules out: min and max, then whatever the caller's calendar props add. */
export function pickerDisabled(min: Date | undefined, max: Date | undefined, extra: Matcher | Matcher[] | undefined): Matcher[] {
return [
...(min ? [{ before: min }] : []),
...(max ? [{ after: max }] : []),
...(extra === undefined ? [] : Array.isArray(extra) ? extra : [extra]),
]
}
export { Calendar, CalendarDayButton }