Calendar
A calendar for selecting a single date, a date range, or a date with time.
npx shadcn@latest add @velaui/calendarpnpm dlx velaui add @velaui/calendaryarn dlx shadcn@latest add @velaui/calendarbunx --bun shadcn@latest add @velaui/calendarveni add calendarInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/calendarpnpm dlx velaui add @velaui/calendaryarn dlx shadcn@latest add @velaui/calendarbunx --bun shadcn@latest add @velaui/calendarExamples
- Default
- Date range
- Centered
- Disabled dates
- Range bounded
- Static label
- Month year picker
Usage
Props
Calendar
| Prop | Type | Default |
|---|---|---|
headerVariant | CalendarHeaderVariant | "default" |
monthLabelClickable | boolean | true |
onMonthLabelClick | () => void | — |
pickerMaxMonth | Date | — |
pickerMinMonth | Date | — |
showOutsideDays | boolean | true |
weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 1 |
Takes these and every prop of CalendarProps.
CalendarCell
| Prop | Type | Default |
|---|---|---|
dot | boolean | false |
Takes these and every prop of CalendarCellProps.
CalendarMonthYearPicker
Takes every prop of CalendarMonthYearPickerProps.
CalendarRange
| Prop | Type | Default |
|---|---|---|
defaultMonth | Date | — |
disabled | Matcher | Matcher[] | — |
endMonth | Date | — |
locale | Partial<import("/app/node_modules/.bun/react-day-picker@10.0.1+93dbe784b8baa420/node_modules/react-day-picker/dist/esm/index").DayPickerLocale> | — |
monthLabelClickable | boolean | — |
onSelect | (range: DateRange | undefined) => void | — |
selected | DateRange | — |
showOutsideDays | boolean | — |
startMonth | Date | — |
weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | — |
Takes these and every prop of CalendarRangeProps.
CalendarPeriodHeader
| Prop | Type | Default |
|---|---|---|
variant | "center" | "default" | "first-in-range" | "last-in-range" | "default" |
Takes these and every prop of CalendarPeriodHeaderProps.
CalendarPeriodIndicator
| Prop | Type | Default |
|---|---|---|
clickable | boolean | true |
Takes these and every prop of CalendarPeriodIndicatorProps.
CalendarPeriodNav
Takes every prop of CalendarPeriodNavProps.
CalendarPeriodNavButton
| Prop | Type | Default |
|---|---|---|
asChild | boolean | — |
size | "sm" | "md" | "lg" | "xs" | "xl" | "icon-xs" | "icon-sm" | "icon-md" | "icon-lg" | "icon-xl" | — |
variant | "outline" | "link" | "destructive" | "ghost" | "primary" | "secondary" | — |
Takes these and every prop of CalendarPeriodNavButtonProps.
Calendar
| Prop | Type | Default |
|---|---|---|
headerVariant | CalendarHeaderVariant | "default" |
monthLabelClickable | boolean | true |
onMonthLabelClick | () => void | — |
pickerMaxMonth | Date | — |
pickerMinMonth | Date | — |
showOutsideDays | boolean | true |
weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 1 |
Takes these and every prop of CalendarProps.
CalendarCell
| Prop | Type | Default |
|---|---|---|
dot | boolean | false |
Takes these and every prop of CalendarCellProps.
CalendarMonthYearPicker
Takes every prop of CalendarMonthYearPickerProps.
CalendarRange
| Prop | Type | Default |
|---|---|---|
defaultMonth | Date | — |
disabled | Matcher | Matcher[] | — |
endMonth | Date | — |
locale | Partial<import("/app/node_modules/.bun/react-day-picker@10.0.1+93dbe784b8baa420/node_modules/react-day-picker/dist/esm/index").DayPickerLocale> | — |
monthLabelClickable | boolean | — |
onSelect | (range: DateRange | undefined) => void | — |
selected | DateRange | — |
showOutsideDays | boolean | — |
startMonth | Date | — |
weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | — |
Takes these and every prop of CalendarRangeProps.
CalendarPeriodHeader
| Prop | Type | Default |
|---|---|---|
variant | "center" | "default" | "first-in-range" | "last-in-range" | "default" |
Takes these and every prop of CalendarPeriodHeaderProps.
CalendarPeriodIndicator
| Prop | Type | Default |
|---|---|---|
clickable | boolean | true |
Takes these and every prop of CalendarPeriodIndicatorProps.
CalendarPeriodNav
Takes every prop of CalendarPeriodNavProps.
CalendarPeriodNavButton
| Prop | Type | Default |
|---|---|---|
size | "sm" | "md" | "lg" | "xs" | "xl" | "icon-xs" | "icon-sm" | "icon-md" | "icon-lg" | "icon-xl" | — |
variant | "link" | "outline" | "destructive" | "ghost" | "primary" | "secondary" | — |
Takes these and every prop of CalendarPeriodNavButtonProps.
Dependencies
- npm
class-variance-authority@0.7.1motion@13.1.0radix-ui@1.6.0react-day-picker@10.0.1- Installs with it
- Button3 icons
- npm
@base-ui/react@1.6.0class-variance-authority@0.7.1motion@13.1.0react-day-picker@10.0.1- Installs with it
- Button3 icons
Source
components/ui/calendar.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { animate } from "motion/react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { ChevronDown } from "@/components/icons/chevron-down";
import { ChevronLeft } from "@/components/icons/chevron-left";
import { ChevronRight } from "@/components/icons/chevron-right";
import {
DayPicker,
getDefaultClassNames,
useDayPicker,
type DateRange,
type Locale,
type Matcher,
} from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CalendarHeaderVariant =
"default" | "center" | "first-in-range" | "last-in-range";
/* -------------------------------------------------------------------------- */
/* Calendar cell */
/* -------------------------------------------------------------------------- */
// One class string, no cva axes: every axis Figma draws on this part
// (Type = Default/Today/In range/Selected/Active/Another,
// State = Default/Hover/Disabled) is a rendering state, so all of it lives in
// data-*/pseudo selectors. The focus border is reserved as transparent on the
// base so entering focus never reflows the 40px box.
const calendarCellClassName = cn(
"relative isolate z-10 flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border border-transparent text-sm leading-[1.4] font-normal text-text-strong transition-colors outline-none select-none",
"hover:bg-bg-surface-1",
"focus-visible:border-ring-neutral-outline focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow",
// Today and the keyboard-active day rest on a subtle disc and step up on hover.
"data-[today=true]:bg-bg-surface-1 data-[today=true]:hover:bg-bg-surface-2",
"data-[active=true]:bg-bg-surface-1 data-[active=true]:hover:bg-bg-surface-2",
// Days from the neighbouring month.
"data-[outside=true]:text-text-note",
// Range endpoints flatten the edge that faces the band, so the disc and the
// middle run together instead of leaving a notch at the join. Each side is
// guarded on the opposite endpoint: a one-day range is both start and end,
// and flattening both edges would square off a cell that should stay a disc.
"data-[range-start=true]:not-data-[range-end=true]:rounded-r-none",
"data-[range-end=true]:not-data-[range-start=true]:rounded-l-none",
// Inside a range: a square full-bleed band rather than a disc.
"data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-bg-surface-1 data-[range-middle=true]:text-text-strong data-[range-middle=true]:hover:bg-bg-surface-2",
// Selected day / range endpoints — the filled disc, which does not shift on hover.
"data-[selected=true]:bg-primary-default data-[selected=true]:text-text-inverse data-[selected=true]:hover:bg-primary-default",
// Disabled reads differently depending on what the cell is.
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:text-text-disabled",
"disabled:data-[today=true]:text-text-note",
"disabled:data-[range-middle=true]:text-text-note",
"disabled:data-[selected=true]:bg-bg-surface-2 disabled:data-[selected=true]:text-text-note",
);
export type CalendarCellProps = React.ComponentProps<"button"> & { dot?: boolean };
function CalendarCell({
className,
children,
dot = false,
...props
}: CalendarCellProps) {
return (
<button
type="button"
data-slot="calendar-cell"
className={cn(calendarCellClassName, className)}
{...props}
>
{children}
{dot ? (
<span
data-slot="calendar-cell-dot"
className="absolute bottom-1 left-1/2 size-[5px] -translate-x-1/2 rounded-full bg-current"
/>
) : null}
</button>
);
}
/* -------------------------------------------------------------------------- */
/* Period header + parts */
/* -------------------------------------------------------------------------- */
const calendarPeriodHeaderVariants = cva(
"flex h-10 w-full items-center gap-3",
{
variants: {
variant: {
// Label sits left, both nav buttons group to the right.
default: "justify-between",
// Nav buttons flank a centred label.
center: "justify-between",
// Leading month of a range — previous only.
"first-in-range": "justify-start",
// Trailing month of a range — next only.
"last-in-range": "justify-between",
},
},
defaultVariants: {
variant: "default",
},
},
);
export type CalendarPeriodHeaderProps = React.ComponentProps<"div"> &
VariantProps<typeof calendarPeriodHeaderVariants>;
function CalendarPeriodHeader({
className,
variant = "default",
...props
}: CalendarPeriodHeaderProps) {
return (
<div
data-slot="calendar-period-header"
data-variant={variant}
className={cn(calendarPeriodHeaderVariants({ variant }), className)}
{...props}
/>
);
}
// The label's type scale is fixed: Figma gives the indicator an isClickable ×
// State matrix and no size axis at all, so there is one set of measurements.
const calendarPeriodIndicatorVariants = cva(
"inline-flex h-[34px] shrink-0 items-center gap-1 rounded-xl border border-transparent px-2 text-xl leading-[1.3] font-semibold text-text-strong transition-colors outline-none select-none [&_svg:not([class*='size-'])]:size-5",
{
variants: {
// Only the clickable indicator carries a surface — the static label has
// no state at all. Its chevron points down at rest and flips up while the
// month & year picker is open, which is the design's only affordance for
// "this panel is showing something other than the day grid".
clickable: {
true: "cursor-pointer hover:bg-bg-surface-1 active:bg-bg-surface-1 aria-expanded:bg-bg-surface-1 focus-visible:border-ring-neutral-outline focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow [&_svg]:transition-transform [&_svg]:duration-200 aria-expanded:[&_svg]:rotate-180",
false: "",
},
},
defaultVariants: {
clickable: true,
},
},
);
export type CalendarPeriodIndicatorProps = Omit<React.ComponentProps<"button">, "type"> &
VariantProps<typeof calendarPeriodIndicatorVariants>;
function CalendarPeriodIndicator({
className,
clickable = true,
children,
...props
}: CalendarPeriodIndicatorProps) {
const classes = cn(calendarPeriodIndicatorVariants({ clickable }), className);
if (!clickable) {
const { onClick: _onClick, disabled: _disabled, ...spanProps } = props;
return (
<span
data-slot="calendar-period-indicator"
className={classes}
{...(spanProps as React.ComponentProps<"span">)}
>
{children}
</span>
);
}
return (
<button
type="button"
data-slot="calendar-period-indicator"
className={classes}
{...props}
>
{children}
<ChevronDown aria-hidden />
</button>
);
}
export type CalendarPeriodNavProps = React.ComponentProps<"div">;
function CalendarPeriodNav({
className,
...props
}: CalendarPeriodNavProps) {
return (
<div
data-slot="calendar-period-nav"
className={cn("flex items-center gap-1", className)}
{...props}
/>
);
}
export type CalendarPeriodNavButtonProps = React.ComponentProps<typeof Button>;
function CalendarPeriodNavButton({
className,
...props
}: CalendarPeriodNavButtonProps) {
return (
<Button
variant="outline"
size="icon-sm"
data-slot="calendar-period-nav-button"
className={cn("text-icon-strong", className)}
{...props}
/>
);
}
/* -------------------------------------------------------------------------- */
/* Calendar */
/* -------------------------------------------------------------------------- */
type CalendarConfig = {
headerVariant: CalendarHeaderVariant;
monthLabelClickable: boolean;
onMonthLabelClick?: () => void;
locale?: Partial<Locale>;
pickerOpen: boolean;
setPickerOpen: (open: boolean) => void;
/** Earliest month the month & year picker may land on, inclusive. */
pickerMinMonth?: Date;
/** Latest month the month & year picker may land on, inclusive. */
pickerMaxMonth?: Date;
};
const CalendarConfigContext = React.createContext<CalendarConfig>({
headerVariant: "default",
monthLabelClickable: true,
pickerOpen: false,
setPickerOpen: () => {},
});
/* -------------------------------------------------------------------------- */
/* Month & year picker */
/* -------------------------------------------------------------------------- */
/** Row height of a wheel — the same 40px module the day cell is built on. */
const CALENDAR_WHEEL_ROW = 40;
/** Five rows are on screen; the pair either side of centre fades back. */
const CALENDAR_WHEEL_PAD = CALENDAR_WHEEL_ROW * 2;
/**
* Whether the browser says when a scroller has come to rest. Everything but
* Safari < 18 does; without it the spring falls back to an idle timer.
*/
const CALENDAR_WHEEL_SCROLLEND =
typeof window !== "undefined" && "onscrollend" in window;
/** Fallback idle window for browsers with no `scrollend`. */
const CALENDAR_WHEEL_SETTLE_MS = 180;
/** How far an unbounded year wheel reaches either side of the current year. */
const CALENDAR_WHEEL_YEARS_BACK = 100;
const CALENDAR_WHEEL_YEARS_FORWARD = 10;
/**
* Carries the wheel onto the nearest row once the gesture is over. Damping is
* kept just under critical for the stiffness (2·√k ≈ 53), so it arrives fast
* without bouncing; `restDelta` ends it as soon as it is inside half a pixel,
* which is a position the scroller cannot render anyway.
*/
const CALENDAR_WHEEL_SPRING = {
type: "spring",
stiffness: 700,
damping: 50,
restDelta: 0.5,
} as const;
type CalendarWheelItem = { value: number; label: string; disabled?: boolean };
/** Months as a single ordinal, so a bound check is one integer comparison. */
function monthOrdinal(year: number, month: number) {
return year * 12 + month;
}
/**
* The design fades a wheel row by its distance from centre — full at centre,
* a quarter one row out, a tenth two rows out — so the falloff is a lookup on
* that distance rather than a selected/unselected pair.
*/
function calendarWheelOpacity(distance: number) {
if (distance === 0) return "opacity-100";
if (distance === 1) return "opacity-25";
return "opacity-10";
}
/**
* The reachable span of a wheel. Bounds always disable a contiguous block at
* one or both ends, so the span is just the first and last enabled row.
*/
function calendarWheelBounds(items: CalendarWheelItem[]) {
let first = -1;
let last = -1;
for (let i = 0; i < items.length; i += 1) {
if (items[i].disabled) continue;
if (first === -1) first = i;
last = i;
}
// Nothing reachable is not a state the picker can produce — the current month
// is always in bounds — but a caller-supplied list could. Fall back to the
// whole range rather than pinning the wheel to row 0.
return first === -1 ? { first: 0, last: items.length - 1 } : { first, last };
}
/**
* One wheel column. The row under the centre line is the value, and the
* scrolling itself is the browser's — there is no CSS snapping, which used to
* pin the wheel outright, and nothing writes `scrollTop` while a gesture is
* live. A spring carries the wheel onto the nearest row afterwards.
*/
function CalendarWheelColumn({
items,
value,
onValueChange,
align,
className,
...props
}: Omit<React.ComponentProps<"div">, "onChange"> & {
items: CalendarWheelItem[];
value: number;
onValueChange: (value: number) => void;
align: "start" | "end";
}) {
const ref = React.useRef<HTMLDivElement>(null);
const spring = React.useRef<ReturnType<typeof animate> | null>(null);
const settle = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const index = Math.max(
0,
items.findIndex((item) => item.value === value),
);
const { first, last } = calendarWheelBounds(items);
// The opening position is assigned outright — the popover mounts with the
// wheel already on the current row, so there is nothing to animate from.
React.useLayoutEffect(() => {
const node = ref.current;
if (node) node.scrollTop = index * CALENDAR_WHEEL_ROW;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/** Ease onto a row, committing it when the wheel gets there. */
const springTo = (target: number) => {
const node = ref.current;
if (!node) return;
spring.current?.stop();
spring.current = animate(node.scrollTop, target * CALENDAR_WHEEL_ROW, {
...CALENDAR_WHEEL_SPRING,
onUpdate: (top) => {
node.scrollTop = top;
},
onComplete: () => {
spring.current = null;
const item = items[target];
if (item && !item.disabled && item.value !== value)
onValueChange(item.value);
},
});
};
/**
* Give the scroller back the moment the user touches it. A spring writes
* `scrollTop` every frame and each write cancels whatever scroll the browser
* was running, so a spring left going during a gesture eats it frame for
* frame. Input events are the only reliable signal — the scroll event cannot
* tell us, because the spring's write is the last one in the frame and the
* position it reports is therefore ours.
*/
React.useEffect(() => {
const node = ref.current;
if (!node) return;
const release = () => {
spring.current?.stop();
spring.current = null;
};
// Passive keeps the scroller on the compositor; capture means a row cannot
// swallow the event on the way down.
const opts = { passive: true, capture: true } as const;
node.addEventListener("wheel", release, opts);
node.addEventListener("touchstart", release, opts);
node.addEventListener("keydown", release, opts);
return () => {
node.removeEventListener("wheel", release, opts);
node.removeEventListener("touchstart", release, opts);
node.removeEventListener("keydown", release, opts);
if (settle.current !== null) clearTimeout(settle.current);
spring.current?.stop();
};
}, []);
/** Whatever row is under the centre line is the value. */
const handleScroll = () => {
const node = ref.current;
if (!node) return;
const landed = Math.round(node.scrollTop / CALENDAR_WHEEL_ROW);
// A disabled row is passed over, never committed — the value holds at the
// last reachable row the gesture crossed.
const item = items[Math.min(last, Math.max(first, landed))];
if (item && !item.disabled && item.value !== value)
onValueChange(item.value);
if (CALENDAR_WHEEL_SCROLLEND) return;
if (settle.current !== null) clearTimeout(settle.current);
settle.current = setTimeout(handleSettle, CALENDAR_WHEEL_SETTLE_MS);
};
/**
* Spring onto the nearest row — but only once the scroller has genuinely
* stopped. `scrollend` fires after momentum has run out, so the spring can
* never interrupt a gesture in flight; an idle timer, which is what this used
* to be, fires while the wheel is still coasting and kills it dead.
*/
const handleSettle = () => {
const node = ref.current;
if (!node || spring.current) return;
const landed = Math.round(node.scrollTop / CALENDAR_WHEEL_ROW);
const target = Math.min(last, Math.max(first, landed));
if (Math.abs(node.scrollTop - target * CALENDAR_WHEEL_ROW) < 1) return;
springTo(target);
};
return (
<div
ref={ref}
data-slot="calendar-wheel-column"
onScroll={handleScroll}
onScrollEnd={handleSettle}
className={cn(
// No CSS snapping: `mandatory` constrains every scroll offset to a snap
// point, so a delta too small to leave the current row's basin is undone
// as fast as it arrives and the wheel refuses to move. The spring below
// does the settling instead, after the gesture rather than during it.
"h-[200px] overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
className,
)}
{...props}
>
<div style={{ paddingBlock: CALENDAR_WHEEL_PAD }}>
{items.map((item, i) => {
return (
<button
key={item.value}
type="button"
data-slot="calendar-wheel-item"
data-selected={i === index || undefined}
// `aria-disabled`, not `disabled`: Chrome does not dispatch mouse
// events over a disabled form control, so a wheel row under the
// cursor would swallow the gesture until the pointer moved off it.
aria-disabled={item.disabled || undefined}
aria-current={i === index ? "true" : undefined}
onClick={() => {
if (item.disabled) return;
springTo(i);
}}
className={cn(
// Same type scale as the period indicator the wheel replaces, so
// the label a user just clicked and the row they land on read as
// the same text.
"flex h-10 w-full items-center text-xl leading-[1.3] font-semibold text-text-strong transition-opacity outline-none select-none",
align === "start"
? "justify-start text-left"
: "justify-end text-right",
calendarWheelOpacity(Math.abs(i - index)),
// A row out of bounds can never be the centred one, so it is
// always already dimmed by distance — the grey only has to
// separate "far away" from "not available".
item.disabled && "cursor-not-allowed text-text-disabled",
)}
>
{item.label}
</button>
);
})}
</div>
</div>
);
}
export type CalendarMonthYearPickerProps = React.ComponentProps<"div">;
/**
* The wheels stage a month and a year; nothing moves until the footer button
* commits them. Scrolling deliberately does NOT navigate — the design pairs the
* wheels with a Done button, and a calendar that jumped under the cursor on
* every scroll tick would make that button meaningless. Committing navigates
* the calendar; it never touches the selection.
*/
function CalendarMonthYearPicker({
className,
...props
}: CalendarMonthYearPickerProps) {
const { locale, setPickerOpen, pickerMinMonth, pickerMaxMonth } =
React.useContext(CalendarConfigContext);
const { months, goToMonth, dayPickerProps } = useDayPicker();
const current = months[0]?.date ?? new Date();
// The picker is mounted fresh each time the popover opens, so seeding the
// draft from the displayed month here is enough — no effect to resync it.
const [draftMonth, setDraftMonth] = React.useState(() => current.getMonth());
const [draftYear, setDraftYear] = React.useState(() => current.getFullYear());
// The panel this picker belongs to may only reach part of the calendar — in a
// range, the "from" month cannot pass "to" and vice versa. Both ends collapse
// to an ordinal so every row check below is one comparison.
const minOrdinal = pickerMinMonth
? monthOrdinal(pickerMinMonth.getFullYear(), pickerMinMonth.getMonth())
: Number.NEGATIVE_INFINITY;
const maxOrdinal = pickerMaxMonth
? monthOrdinal(pickerMaxMonth.getFullYear(), pickerMaxMonth.getMonth())
: Number.POSITIVE_INFINITY;
// Which months are reachable depends on the year the other wheel is parked
// on, so this list is rebuilt whenever the draft year moves.
const monthItems = React.useMemo<CalendarWheelItem[]>(
() =>
Array.from({ length: 12 }, (_, month) => {
const ordinal = monthOrdinal(draftYear, month);
return {
value: month,
label: new Date(2000, month, 1).toLocaleString(locale?.code, {
month: "long",
}),
disabled: ordinal < minOrdinal || ordinal > maxOrdinal,
};
}),
[locale?.code, draftYear, minOrdinal, maxOrdinal],
);
// Changing the year can strand the month wheel out of bounds — December 2024
// is fine until the year drops to 2023 and the bound sits at June. Pull it
// back to the nearest reachable month rather than leaving an invalid draft.
React.useEffect(() => {
const ordinal = monthOrdinal(draftYear, draftMonth);
if (ordinal >= minOrdinal && ordinal <= maxOrdinal) return;
const clamped = Math.min(Math.max(ordinal, minOrdinal), maxOrdinal);
const month = clamped - draftYear * 12;
if (month >= 0 && month <= 11) setDraftMonth(month);
}, [draftYear, draftMonth, minOrdinal, maxOrdinal]);
// Both anchors are frozen on open: deriving them from `current` would slide
// the whole year list out from under the wheel on every pick.
const [anchorYear] = React.useState(() => current.getFullYear());
const [thisYear] = React.useState(() => new Date().getFullYear());
const yearItems = React.useMemo<CalendarWheelItem[]>(() => {
// An unbounded calendar offers a century back and a decade forward — far
// enough for a date of birth, which is the case that needs the reach. The
// displayed year widens the window if it somehow falls outside, so the
// month on screen is always one the wheel can show.
const from =
dayPickerProps.startMonth?.getFullYear() ??
Math.min(thisYear - CALENDAR_WHEEL_YEARS_BACK, anchorYear);
const to =
dayPickerProps.endMonth?.getFullYear() ??
Math.max(thisYear + CALENDAR_WHEEL_YEARS_FORWARD, anchorYear);
return Array.from({ length: Math.max(1, to - from + 1) }, (_, i) => {
const year = from + i;
return {
value: year,
label: String(year),
// A year is out of reach only when EVERY month in it is — otherwise it
// stays selectable and the month wheel does the narrowing.
disabled:
monthOrdinal(year, 11) < minOrdinal ||
monthOrdinal(year, 0) > maxOrdinal,
};
});
}, [
dayPickerProps.startMonth,
dayPickerProps.endMonth,
anchorYear,
thisYear,
minOrdinal,
maxOrdinal,
]);
return (
<div
data-slot="calendar-month-year-picker"
className={cn("flex w-[250px] flex-col", className)}
{...props}
>
<div className="px-6 py-5">
{/* "September" is roughly half again as wide as "2024", so the columns
split the row 3:2 rather than evenly — an even split clips the long
month names. */}
<div className="flex items-center gap-1 px-2">
<CalendarWheelColumn
aria-label="Month"
align="start"
className="min-w-0 flex-[3]"
items={monthItems}
value={draftMonth}
onValueChange={setDraftMonth}
/>
<CalendarWheelColumn
aria-label="Year"
align="end"
className="min-w-0 flex-[2]"
items={yearItems}
value={draftYear}
onValueChange={setDraftYear}
/>
</div>
</div>
<div
data-slot="calendar-month-year-picker-footer"
className="flex items-center gap-3 border-t border-border-default p-4"
>
<Button
variant="secondary"
className="w-full"
onClick={() => {
goToMonth(new Date(draftYear, draftMonth, 1));
setPickerOpen(false);
}}
>
Done
</Button>
</div>
</div>
);
}
function CalendarCaption({ month }: { month: Date }) {
const {
headerVariant,
monthLabelClickable,
onMonthLabelClick,
locale,
pickerOpen,
setPickerOpen,
} = React.useContext(CalendarConfigContext);
const { goToMonth, previousMonth, nextMonth } = useDayPicker();
const label = month.toLocaleString(locale?.code, {
month: "long",
year: "numeric",
});
const previous = (
<CalendarPeriodNavButton
aria-label="Previous month"
disabled={!previousMonth}
onClick={() => previousMonth && goToMonth(previousMonth)}
>
<ChevronLeft aria-hidden />
</CalendarPeriodNavButton>
);
const next = (
<CalendarPeriodNavButton
aria-label="Next month"
disabled={!nextMonth}
onClick={() => nextMonth && goToMonth(nextMonth)}
>
<ChevronRight aria-hidden />
</CalendarPeriodNavButton>
);
// The static label has nothing to open, so it stays a bare indicator — no
// popover machinery around a span that cannot be clicked. Radix owns
// `aria-expanded` on the trigger, which is what drives both the open surface
// and the chevron flip in calendarPeriodIndicatorVariants.
const indicator = monthLabelClickable ? (
<PopoverPrimitive.Root
open={pickerOpen}
onOpenChange={(open) => {
setPickerOpen(open);
if (open) onMonthLabelClick?.();
}}
>
<PopoverPrimitive.Trigger asChild>
<CalendarPeriodIndicator clickable>{label}</CalendarPeriodIndicator>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="calendar-period-popover"
align="start"
sideOffset={8}
// Padding lives on the picker, not here — its footer is full-bleed
// against the surface, so the panel has to clip rather than pad.
className="z-50 origin-(--radix-popover-content-transform-origin) overflow-hidden rounded-xl border border-border-default bg-bg-main text-text-strong shadow-sm outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
>
<CalendarMonthYearPicker />
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
) : (
<CalendarPeriodIndicator clickable={false}>{label}</CalendarPeriodIndicator>
);
return (
<CalendarPeriodHeader variant={headerVariant}>
{headerVariant === "center" || headerVariant === "first-in-range"
? previous
: null}
{indicator}
{headerVariant === "default" ? (
<CalendarPeriodNav>
{previous}
{next}
</CalendarPeriodNav>
) : null}
{headerVariant === "center" || headerVariant === "last-in-range"
? next
: null}
</CalendarPeriodHeader>
);
}
export type CalendarProps = React.ComponentProps<typeof DayPicker> & {
headerVariant?: CalendarHeaderVariant;
monthLabelClickable?: boolean;
onMonthLabelClick?: () => void;
pickerMinMonth?: Date;
pickerMaxMonth?: Date;
};
function Calendar({
className,
classNames,
showOutsideDays = true,
weekStartsOn = 1,
headerVariant = "default",
monthLabelClickable = true,
onMonthLabelClick,
locale,
formatters,
components,
pickerMinMonth,
pickerMaxMonth,
...props
}: CalendarProps) {
const defaultClassNames = getDefaultClassNames();
const [pickerOpen, setPickerOpen] = React.useState(false);
const config = React.useMemo<CalendarConfig>(
() => ({
headerVariant,
monthLabelClickable,
onMonthLabelClick,
locale,
pickerOpen,
setPickerOpen,
pickerMinMonth,
pickerMaxMonth,
}),
[
headerVariant,
monthLabelClickable,
onMonthLabelClick,
locale,
pickerOpen,
pickerMinMonth,
pickerMaxMonth,
],
);
return (
<CalendarConfigContext value={config}>
<DayPicker
showOutsideDays={showOutsideDays}
weekStartsOn={weekStartsOn}
locale={locale}
className={cn("group/calendar w-fit bg-bg-main px-6 py-5", className)}
formatters={{
formatWeekdayName: (date) =>
date.toLocaleString(locale?.code, { weekday: "short" }).slice(0, 2),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("flex flex-col", defaultClassNames.months),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
// The header is rendered by MonthCaption — react-day-picker's own nav
// row would duplicate it.
nav: cn("hidden", defaultClassNames.nav),
month_caption: cn("w-full", defaultClassNames.month_caption),
month_grid: cn(
"w-full border-collapse",
defaultClassNames.month_grid,
),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex size-10 items-center justify-center text-sm leading-[1.4] font-normal text-text-note select-none",
defaultClassNames.weekday,
),
week: cn("flex w-full", defaultClassNames.week),
day: cn("group/day relative p-0 text-center", defaultClassNames.day),
// The range band is painted behind the endpoint disc — half a cell on
// the inner side of each endpoint.
range_start: cn(
"relative isolate z-0 after:absolute after:inset-y-0 after:right-0 after:-z-10 after:w-5 after:bg-bg-surface-1",
defaultClassNames.range_start,
),
range_end: cn(
"relative isolate z-0 after:absolute after:inset-y-0 after:left-0 after:-z-10 after:w-5 after:bg-bg-surface-1",
defaultClassNames.range_end,
),
range_middle: cn(
"relative isolate z-0",
defaultClassNames.range_middle,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...rootProps }) => (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...rootProps}
/>
),
MonthCaption: ({ calendarMonth }) => (
<CalendarCaption month={calendarMonth.date} />
),
Chevron: ({ className, orientation, ...chevronProps }) => {
if (orientation === "left") {
return (
<ChevronLeft
className={cn("size-4", className)}
{...chevronProps}
/>
);
}
if (orientation === "right") {
return (
<ChevronRight
className={cn("size-4", className)}
{...chevronProps}
/>
);
}
return (
<ChevronDown
className={cn("size-4", className)}
{...chevronProps}
/>
);
},
DayButton: ({ className, day, modifiers, ...dayProps }) => {
const isEndpoint = !!modifiers.selected && !modifiers.range_middle;
return (
<CalendarCell
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected={isEndpoint || undefined}
data-range-start={modifiers.range_start || undefined}
data-range-end={modifiers.range_end || undefined}
data-range-middle={modifiers.range_middle || undefined}
data-today={
(modifiers.today && !modifiers.selected) || undefined
}
data-active={modifiers.focused || undefined}
data-outside={modifiers.outside || undefined}
className={cn(className)}
{...dayProps}
/>
);
},
...components,
}}
{...props}
/>
</CalendarConfigContext>
);
}
/* -------------------------------------------------------------------------- */
/* Calendar range */
/* -------------------------------------------------------------------------- */
export type CalendarRangeProps = Omit<
React.ComponentProps<"div">,
"onSelect"
> & {
selected?: DateRange;
onSelect?: (range: DateRange | undefined) => void;
/** Month the left panel opens on; the right opens a month later. */
defaultMonth?: Date;
startMonth?: Date;
endMonth?: Date;
disabled?: Matcher | Matcher[];
locale?: Partial<Locale>;
showOutsideDays?: boolean;
weekStartsOn?: React.ComponentProps<typeof Calendar>["weekStartsOn"];
monthLabelClickable?: boolean;
};
/** First of the month — the two panels compare and store months, never days. */
function startOfMonth(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
function addMonths(date: Date, amount: number) {
return new Date(date.getFullYear(), date.getMonth() + amount, 1);
}
/**
* The design's dual-date layout: two panels split by a rule, the left carrying
* only a previous arrow and the right only a next one.
*
* The panels hold their months INDEPENDENTLY — this is not one DayPicker
* showing two months, because that forces them consecutive. Independence is
* what makes `from <= to` violable, so each panel's month & year picker is
* bounded by the other's month: the left can never pass the right, and the
* right can never fall behind the left.
*/
function CalendarRange({
className,
selected,
onSelect,
defaultMonth,
startMonth,
endMonth,
disabled,
locale,
showOutsideDays,
weekStartsOn,
monthLabelClickable,
...props
}: CalendarRangeProps) {
const [fromMonth, setFromMonth] = React.useState(() =>
startOfMonth(defaultMonth ?? selected?.from ?? new Date()),
);
const [toMonth, setToMonth] = React.useState(() =>
addMonths(defaultMonth ?? selected?.from ?? new Date(), 1),
);
// The bound is `from <= to`, so both panels are allowed to rest on the SAME
// month. The arrows can only widen the pair — the left has no next and the
// right has no previous — so they cannot cross on their own. The pushes below
// are the backstop for anything that sets a month directly.
const handleFromMonth = (month: Date) => {
const next = startOfMonth(month);
setFromMonth(next);
setToMonth((to) => (to < next ? next : to));
};
const handleToMonth = (month: Date) => {
const next = startOfMonth(month);
setToMonth(next);
setFromMonth((from) => (from > next ? next : from));
};
const shared = {
mode: "range",
selected,
onSelect,
startMonth,
endMonth,
disabled,
locale,
showOutsideDays,
weekStartsOn,
monthLabelClickable,
} as const;
return (
<div
data-slot="calendar-range"
className={cn("flex divide-x divide-border-default", className)}
{...props}
>
<Calendar
{...shared}
headerVariant="first-in-range"
month={fromMonth}
onMonthChange={handleFromMonth}
pickerMaxMonth={toMonth}
/>
<Calendar
{...shared}
headerVariant="last-in-range"
month={toMonth}
onMonthChange={handleToMonth}
pickerMinMonth={fromMonth}
/>
</div>
);
}
export {
Calendar,
CalendarCell,
CalendarMonthYearPicker,
CalendarRange,
CalendarPeriodHeader,
CalendarPeriodIndicator,
CalendarPeriodNav,
CalendarPeriodNavButton,
calendarPeriodHeaderVariants,
calendarPeriodIndicatorVariants,
};components/ui/calendar.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { animate } from "motion/react";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { cva, type VariantProps } from "class-variance-authority";
import { ChevronDown } from "@/components/icons/chevron-down";
import { ChevronLeft } from "@/components/icons/chevron-left";
import { ChevronRight } from "@/components/icons/chevron-right";
import {
DayPicker,
getDefaultClassNames,
useDayPicker,
type DateRange,
type Locale,
type Matcher,
} from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CalendarHeaderVariant =
"default" | "center" | "first-in-range" | "last-in-range";
/* -------------------------------------------------------------------------- */
/* Calendar cell */
/* -------------------------------------------------------------------------- */
// One class string, no cva axes: every axis Figma draws on this part
// (Type = Default/Today/In range/Selected/Active/Another,
// State = Default/Hover/Disabled) is a rendering state, so all of it lives in
// data-*/pseudo selectors. The focus border is reserved as transparent on the
// base so entering focus never reflows the 40px box.
const calendarCellClassName = cn(
"relative isolate z-10 flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border border-transparent text-sm leading-[1.4] font-normal text-text-strong transition-colors outline-none select-none",
"hover:bg-bg-surface-1",
"focus-visible:border-ring-neutral-outline focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow",
// Today and the keyboard-active day rest on a subtle disc and step up on hover.
"data-[today=true]:bg-bg-surface-1 data-[today=true]:hover:bg-bg-surface-2",
"data-[active=true]:bg-bg-surface-1 data-[active=true]:hover:bg-bg-surface-2",
// Days from the neighbouring month.
"data-[outside=true]:text-text-note",
// Range endpoints flatten the edge that faces the band, so the disc and the
// middle run together instead of leaving a notch at the join. Each side is
// guarded on the opposite endpoint: a one-day range is both start and end,
// and flattening both edges would square off a cell that should stay a disc.
"data-[range-start=true]:not-data-[range-end=true]:rounded-r-none",
"data-[range-end=true]:not-data-[range-start=true]:rounded-l-none",
// Inside a range: a square full-bleed band rather than a disc.
"data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-bg-surface-1 data-[range-middle=true]:text-text-strong data-[range-middle=true]:hover:bg-bg-surface-2",
// Selected day / range endpoints — the filled disc, which does not shift on hover.
"data-[selected=true]:bg-primary-default data-[selected=true]:text-text-inverse data-[selected=true]:hover:bg-primary-default",
// Disabled reads differently depending on what the cell is.
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:text-text-disabled",
"disabled:data-[today=true]:text-text-note",
"disabled:data-[range-middle=true]:text-text-note",
"disabled:data-[selected=true]:bg-bg-surface-2 disabled:data-[selected=true]:text-text-note",
);
export type CalendarCellProps = React.ComponentProps<"button"> & { dot?: boolean };
function CalendarCell({
className,
children,
dot = false,
...props
}: CalendarCellProps) {
return (
<button
type="button"
data-slot="calendar-cell"
className={cn(calendarCellClassName, className)}
{...props}
>
{children}
{dot ? (
<span
data-slot="calendar-cell-dot"
className="absolute bottom-1 left-1/2 size-[5px] -translate-x-1/2 rounded-full bg-current"
/>
) : null}
</button>
);
}
/* -------------------------------------------------------------------------- */
/* Period header + parts */
/* -------------------------------------------------------------------------- */
const calendarPeriodHeaderVariants = cva(
"flex h-10 w-full items-center gap-3",
{
variants: {
variant: {
// Label sits left, both nav buttons group to the right.
default: "justify-between",
// Nav buttons flank a centred label.
center: "justify-between",
// Leading month of a range — previous only.
"first-in-range": "justify-start",
// Trailing month of a range — next only.
"last-in-range": "justify-between",
},
},
defaultVariants: {
variant: "default",
},
},
);
export type CalendarPeriodHeaderProps = React.ComponentProps<"div"> &
VariantProps<typeof calendarPeriodHeaderVariants>;
function CalendarPeriodHeader({
className,
variant = "default",
...props
}: CalendarPeriodHeaderProps) {
return (
<div
data-slot="calendar-period-header"
data-variant={variant}
className={cn(calendarPeriodHeaderVariants({ variant }), className)}
{...props}
/>
);
}
// The label's type scale is fixed: Figma gives the indicator an isClickable ×
// State matrix and no size axis at all, so there is one set of measurements.
const calendarPeriodIndicatorVariants = cva(
"inline-flex h-[34px] shrink-0 items-center gap-1 rounded-xl border border-transparent px-2 text-xl leading-[1.3] font-semibold text-text-strong transition-colors outline-none select-none [&_svg:not([class*='size-'])]:size-5",
{
variants: {
// Only the clickable indicator carries a surface — the static label has
// no state at all. Its chevron points down at rest and flips up while the
// month & year picker is open, which is the design's only affordance for
// "this panel is showing something other than the day grid".
clickable: {
true: "cursor-pointer hover:bg-bg-surface-1 active:bg-bg-surface-1 aria-expanded:bg-bg-surface-1 focus-visible:border-ring-neutral-outline focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow [&_svg]:transition-transform [&_svg]:duration-200 aria-expanded:[&_svg]:rotate-180",
false: "",
},
},
defaultVariants: {
clickable: true,
},
},
);
export type CalendarPeriodIndicatorProps = Omit<React.ComponentProps<"button">, "type"> &
VariantProps<typeof calendarPeriodIndicatorVariants>;
function CalendarPeriodIndicator({
className,
clickable = true,
children,
...props
}: CalendarPeriodIndicatorProps) {
const classes = cn(calendarPeriodIndicatorVariants({ clickable }), className);
if (!clickable) {
const { onClick: _onClick, disabled: _disabled, ...spanProps } = props;
return (
<span
data-slot="calendar-period-indicator"
className={classes}
{...(spanProps as React.ComponentProps<"span">)}
>
{children}
</span>
);
}
return (
<button
type="button"
data-slot="calendar-period-indicator"
className={classes}
{...props}
>
{children}
<ChevronDown aria-hidden />
</button>
);
}
export type CalendarPeriodNavProps = React.ComponentProps<"div">;
function CalendarPeriodNav({
className,
...props
}: CalendarPeriodNavProps) {
return (
<div
data-slot="calendar-period-nav"
className={cn("flex items-center gap-1", className)}
{...props}
/>
);
}
export type CalendarPeriodNavButtonProps = React.ComponentProps<typeof Button>;
function CalendarPeriodNavButton({
className,
...props
}: CalendarPeriodNavButtonProps) {
return (
<Button
variant="outline"
size="icon-sm"
data-slot="calendar-period-nav-button"
className={cn("text-icon-strong", className)}
{...props}
/>
);
}
/* -------------------------------------------------------------------------- */
/* Calendar */
/* -------------------------------------------------------------------------- */
type CalendarConfig = {
headerVariant: CalendarHeaderVariant;
monthLabelClickable: boolean;
onMonthLabelClick?: () => void;
locale?: Partial<Locale>;
pickerOpen: boolean;
setPickerOpen: (open: boolean) => void;
/** Earliest month the month & year picker may land on, inclusive. */
pickerMinMonth?: Date;
/** Latest month the month & year picker may land on, inclusive. */
pickerMaxMonth?: Date;
};
const CalendarConfigContext = React.createContext<CalendarConfig>({
headerVariant: "default",
monthLabelClickable: true,
pickerOpen: false,
setPickerOpen: () => {},
});
/* -------------------------------------------------------------------------- */
/* Month & year picker */
/* -------------------------------------------------------------------------- */
/** Row height of a wheel — the same 40px module the day cell is built on. */
const CALENDAR_WHEEL_ROW = 40;
/** Five rows are on screen; the pair either side of centre fades back. */
const CALENDAR_WHEEL_PAD = CALENDAR_WHEEL_ROW * 2;
/**
* Whether the browser says when a scroller has come to rest. Everything but
* Safari < 18 does; without it the spring falls back to an idle timer.
*/
const CALENDAR_WHEEL_SCROLLEND =
typeof window !== "undefined" && "onscrollend" in window;
/** Fallback idle window for browsers with no `scrollend`. */
const CALENDAR_WHEEL_SETTLE_MS = 180;
/** How far an unbounded year wheel reaches either side of the current year. */
const CALENDAR_WHEEL_YEARS_BACK = 100;
const CALENDAR_WHEEL_YEARS_FORWARD = 10;
/**
* Carries the wheel onto the nearest row once the gesture is over. Damping is
* kept just under critical for the stiffness (2·√k ≈ 53), so it arrives fast
* without bouncing; `restDelta` ends it as soon as it is inside half a pixel,
* which is a position the scroller cannot render anyway.
*/
const CALENDAR_WHEEL_SPRING = {
type: "spring",
stiffness: 700,
damping: 50,
restDelta: 0.5,
} as const;
type CalendarWheelItem = { value: number; label: string; disabled?: boolean };
/** Months as a single ordinal, so a bound check is one integer comparison. */
function monthOrdinal(year: number, month: number) {
return year * 12 + month;
}
/**
* The design fades a wheel row by its distance from centre — full at centre,
* a quarter one row out, a tenth two rows out — so the falloff is a lookup on
* that distance rather than a selected/unselected pair.
*/
function calendarWheelOpacity(distance: number) {
if (distance === 0) return "opacity-100";
if (distance === 1) return "opacity-25";
return "opacity-10";
}
/**
* The reachable span of a wheel. Bounds always disable a contiguous block at
* one or both ends, so the span is just the first and last enabled row.
*/
function calendarWheelBounds(items: CalendarWheelItem[]) {
let first = -1;
let last = -1;
for (let i = 0; i < items.length; i += 1) {
if (items[i].disabled) continue;
if (first === -1) first = i;
last = i;
}
// Nothing reachable is not a state the picker can produce — the current month
// is always in bounds — but a caller-supplied list could. Fall back to the
// whole range rather than pinning the wheel to row 0.
return first === -1 ? { first: 0, last: items.length - 1 } : { first, last };
}
/**
* One wheel column. The row under the centre line is the value, and the
* scrolling itself is the browser's — there is no CSS snapping, which used to
* pin the wheel outright, and nothing writes `scrollTop` while a gesture is
* live. A spring carries the wheel onto the nearest row afterwards.
*/
function CalendarWheelColumn({
items,
value,
onValueChange,
align,
className,
...props
}: Omit<React.ComponentProps<"div">, "onChange"> & {
items: CalendarWheelItem[];
value: number;
onValueChange: (value: number) => void;
align: "start" | "end";
}) {
const ref = React.useRef<HTMLDivElement>(null);
const spring = React.useRef<ReturnType<typeof animate> | null>(null);
const settle = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const index = Math.max(
0,
items.findIndex((item) => item.value === value),
);
const { first, last } = calendarWheelBounds(items);
// The opening position is assigned outright — the popover mounts with the
// wheel already on the current row, so there is nothing to animate from.
React.useLayoutEffect(() => {
const node = ref.current;
if (node) node.scrollTop = index * CALENDAR_WHEEL_ROW;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/** Ease onto a row, committing it when the wheel gets there. */
const springTo = (target: number) => {
const node = ref.current;
if (!node) return;
spring.current?.stop();
spring.current = animate(node.scrollTop, target * CALENDAR_WHEEL_ROW, {
...CALENDAR_WHEEL_SPRING,
onUpdate: (top) => {
node.scrollTop = top;
},
onComplete: () => {
spring.current = null;
const item = items[target];
if (item && !item.disabled && item.value !== value)
onValueChange(item.value);
},
});
};
/**
* Give the scroller back the moment the user touches it. A spring writes
* `scrollTop` every frame and each write cancels whatever scroll the browser
* was running, so a spring left going during a gesture eats it frame for
* frame. Input events are the only reliable signal — the scroll event cannot
* tell us, because the spring's write is the last one in the frame and the
* position it reports is therefore ours.
*/
React.useEffect(() => {
const node = ref.current;
if (!node) return;
const release = () => {
spring.current?.stop();
spring.current = null;
};
// Passive keeps the scroller on the compositor; capture means a row cannot
// swallow the event on the way down.
const opts = { passive: true, capture: true } as const;
node.addEventListener("wheel", release, opts);
node.addEventListener("touchstart", release, opts);
node.addEventListener("keydown", release, opts);
return () => {
node.removeEventListener("wheel", release, opts);
node.removeEventListener("touchstart", release, opts);
node.removeEventListener("keydown", release, opts);
if (settle.current !== null) clearTimeout(settle.current);
spring.current?.stop();
};
}, []);
/** Whatever row is under the centre line is the value. */
const handleScroll = () => {
const node = ref.current;
if (!node) return;
const landed = Math.round(node.scrollTop / CALENDAR_WHEEL_ROW);
// A disabled row is passed over, never committed — the value holds at the
// last reachable row the gesture crossed.
const item = items[Math.min(last, Math.max(first, landed))];
if (item && !item.disabled && item.value !== value)
onValueChange(item.value);
if (CALENDAR_WHEEL_SCROLLEND) return;
if (settle.current !== null) clearTimeout(settle.current);
settle.current = setTimeout(handleSettle, CALENDAR_WHEEL_SETTLE_MS);
};
/**
* Spring onto the nearest row — but only once the scroller has genuinely
* stopped. `scrollend` fires after momentum has run out, so the spring can
* never interrupt a gesture in flight; an idle timer, which is what this used
* to be, fires while the wheel is still coasting and kills it dead.
*/
const handleSettle = () => {
const node = ref.current;
if (!node || spring.current) return;
const landed = Math.round(node.scrollTop / CALENDAR_WHEEL_ROW);
const target = Math.min(last, Math.max(first, landed));
if (Math.abs(node.scrollTop - target * CALENDAR_WHEEL_ROW) < 1) return;
springTo(target);
};
return (
<div
ref={ref}
data-slot="calendar-wheel-column"
onScroll={handleScroll}
onScrollEnd={handleSettle}
className={cn(
// No CSS snapping: `mandatory` constrains every scroll offset to a snap
// point, so a delta too small to leave the current row's basin is undone
// as fast as it arrives and the wheel refuses to move. The spring below
// does the settling instead, after the gesture rather than during it.
"h-[200px] overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
className,
)}
{...props}
>
<div style={{ paddingBlock: CALENDAR_WHEEL_PAD }}>
{items.map((item, i) => {
return (
<button
key={item.value}
type="button"
data-slot="calendar-wheel-item"
data-selected={i === index || undefined}
// `aria-disabled`, not `disabled`: Chrome does not dispatch mouse
// events over a disabled form control, so a wheel row under the
// cursor would swallow the gesture until the pointer moved off it.
aria-disabled={item.disabled || undefined}
aria-current={i === index ? "true" : undefined}
onClick={() => {
if (item.disabled) return;
springTo(i);
}}
className={cn(
// Same type scale as the period indicator the wheel replaces, so
// the label a user just clicked and the row they land on read as
// the same text.
"flex h-10 w-full items-center text-xl leading-[1.3] font-semibold text-text-strong transition-opacity outline-none select-none",
align === "start"
? "justify-start text-left"
: "justify-end text-right",
calendarWheelOpacity(Math.abs(i - index)),
// A row out of bounds can never be the centred one, so it is
// always already dimmed by distance — the grey only has to
// separate "far away" from "not available".
item.disabled && "cursor-not-allowed text-text-disabled",
)}
>
{item.label}
</button>
);
})}
</div>
</div>
);
}
export type CalendarMonthYearPickerProps = React.ComponentProps<"div">;
/**
* The wheels stage a month and a year; nothing moves until the footer button
* commits them. Scrolling deliberately does NOT navigate — the design pairs the
* wheels with a Done button, and a calendar that jumped under the cursor on
* every scroll tick would make that button meaningless. Committing navigates
* the calendar; it never touches the selection.
*/
function CalendarMonthYearPicker({
className,
...props
}: CalendarMonthYearPickerProps) {
const { locale, setPickerOpen, pickerMinMonth, pickerMaxMonth } =
React.useContext(CalendarConfigContext);
const { months, goToMonth, dayPickerProps } = useDayPicker();
const current = months[0]?.date ?? new Date();
// The picker is mounted fresh each time the popover opens, so seeding the
// draft from the displayed month here is enough — no effect to resync it.
const [draftMonth, setDraftMonth] = React.useState(() => current.getMonth());
const [draftYear, setDraftYear] = React.useState(() => current.getFullYear());
// The panel this picker belongs to may only reach part of the calendar — in a
// range, the "from" month cannot pass "to" and vice versa. Both ends collapse
// to an ordinal so every row check below is one comparison.
const minOrdinal = pickerMinMonth
? monthOrdinal(pickerMinMonth.getFullYear(), pickerMinMonth.getMonth())
: Number.NEGATIVE_INFINITY;
const maxOrdinal = pickerMaxMonth
? monthOrdinal(pickerMaxMonth.getFullYear(), pickerMaxMonth.getMonth())
: Number.POSITIVE_INFINITY;
// Which months are reachable depends on the year the other wheel is parked
// on, so this list is rebuilt whenever the draft year moves.
const monthItems = React.useMemo<CalendarWheelItem[]>(
() =>
Array.from({ length: 12 }, (_, month) => {
const ordinal = monthOrdinal(draftYear, month);
return {
value: month,
label: new Date(2000, month, 1).toLocaleString(locale?.code, {
month: "long",
}),
disabled: ordinal < minOrdinal || ordinal > maxOrdinal,
};
}),
[locale?.code, draftYear, minOrdinal, maxOrdinal],
);
// Changing the year can strand the month wheel out of bounds — December 2024
// is fine until the year drops to 2023 and the bound sits at June. Pull it
// back to the nearest reachable month rather than leaving an invalid draft.
React.useEffect(() => {
const ordinal = monthOrdinal(draftYear, draftMonth);
if (ordinal >= minOrdinal && ordinal <= maxOrdinal) return;
const clamped = Math.min(Math.max(ordinal, minOrdinal), maxOrdinal);
const month = clamped - draftYear * 12;
if (month >= 0 && month <= 11) setDraftMonth(month);
}, [draftYear, draftMonth, minOrdinal, maxOrdinal]);
// Both anchors are frozen on open: deriving them from `current` would slide
// the whole year list out from under the wheel on every pick.
const [anchorYear] = React.useState(() => current.getFullYear());
const [thisYear] = React.useState(() => new Date().getFullYear());
const yearItems = React.useMemo<CalendarWheelItem[]>(() => {
// An unbounded calendar offers a century back and a decade forward — far
// enough for a date of birth, which is the case that needs the reach. The
// displayed year widens the window if it somehow falls outside, so the
// month on screen is always one the wheel can show.
const from =
dayPickerProps.startMonth?.getFullYear() ??
Math.min(thisYear - CALENDAR_WHEEL_YEARS_BACK, anchorYear);
const to =
dayPickerProps.endMonth?.getFullYear() ??
Math.max(thisYear + CALENDAR_WHEEL_YEARS_FORWARD, anchorYear);
return Array.from({ length: Math.max(1, to - from + 1) }, (_, i) => {
const year = from + i;
return {
value: year,
label: String(year),
// A year is out of reach only when EVERY month in it is — otherwise it
// stays selectable and the month wheel does the narrowing.
disabled:
monthOrdinal(year, 11) < minOrdinal ||
monthOrdinal(year, 0) > maxOrdinal,
};
});
}, [
dayPickerProps.startMonth,
dayPickerProps.endMonth,
anchorYear,
thisYear,
minOrdinal,
maxOrdinal,
]);
return (
<div
data-slot="calendar-month-year-picker"
className={cn("flex w-[250px] flex-col", className)}
{...props}
>
<div className="px-6 py-5">
{/* "September" is roughly half again as wide as "2024", so the columns
split the row 3:2 rather than evenly — an even split clips the long
month names. */}
<div className="flex items-center gap-1 px-2">
<CalendarWheelColumn
aria-label="Month"
align="start"
className="min-w-0 flex-[3]"
items={monthItems}
value={draftMonth}
onValueChange={setDraftMonth}
/>
<CalendarWheelColumn
aria-label="Year"
align="end"
className="min-w-0 flex-[2]"
items={yearItems}
value={draftYear}
onValueChange={setDraftYear}
/>
</div>
</div>
<div
data-slot="calendar-month-year-picker-footer"
className="flex items-center gap-3 border-t border-border-default p-4"
>
<Button
variant="secondary"
className="w-full"
onClick={() => {
goToMonth(new Date(draftYear, draftMonth, 1));
setPickerOpen(false);
}}
>
Done
</Button>
</div>
</div>
);
}
function CalendarCaption({ month }: { month: Date }) {
const {
headerVariant,
monthLabelClickable,
onMonthLabelClick,
locale,
pickerOpen,
setPickerOpen,
} = React.useContext(CalendarConfigContext);
const { goToMonth, previousMonth, nextMonth } = useDayPicker();
const label = month.toLocaleString(locale?.code, {
month: "long",
year: "numeric",
});
const previous = (
<CalendarPeriodNavButton
aria-label="Previous month"
disabled={!previousMonth}
onClick={() => previousMonth && goToMonth(previousMonth)}
>
<ChevronLeft aria-hidden />
</CalendarPeriodNavButton>
);
const next = (
<CalendarPeriodNavButton
aria-label="Next month"
disabled={!nextMonth}
onClick={() => nextMonth && goToMonth(nextMonth)}
>
<ChevronRight aria-hidden />
</CalendarPeriodNavButton>
);
// The static label has nothing to open, so it stays a bare indicator — no
// popover machinery around a span that cannot be clicked. Base UI owns
// `aria-expanded` on the trigger, which is what drives both the open surface
// and the chevron flip in calendarPeriodIndicatorVariants.
const indicator = monthLabelClickable ? (
<PopoverPrimitive.Root
open={pickerOpen}
onOpenChange={(open) => {
setPickerOpen(open);
if (open) onMonthLabelClick?.();
}}
>
<PopoverPrimitive.Trigger
render={
<CalendarPeriodIndicator clickable>{label}</CalendarPeriodIndicator>
}
/>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align="start"
sideOffset={8}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="calendar-period-popover"
// Padding lives on the picker, not here — its footer is full-bleed
// against the surface, so the panel has to clip rather than pad.
className="z-50 origin-(--transform-origin) overflow-hidden rounded-xl border border-border-default bg-bg-main text-text-strong shadow-sm outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
>
<CalendarMonthYearPicker />
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
) : (
<CalendarPeriodIndicator clickable={false}>{label}</CalendarPeriodIndicator>
);
return (
<CalendarPeriodHeader variant={headerVariant}>
{headerVariant === "center" || headerVariant === "first-in-range"
? previous
: null}
{indicator}
{headerVariant === "default" ? (
<CalendarPeriodNav>
{previous}
{next}
</CalendarPeriodNav>
) : null}
{headerVariant === "center" || headerVariant === "last-in-range"
? next
: null}
</CalendarPeriodHeader>
);
}
export type CalendarProps = React.ComponentProps<typeof DayPicker> & {
headerVariant?: CalendarHeaderVariant;
monthLabelClickable?: boolean;
onMonthLabelClick?: () => void;
pickerMinMonth?: Date;
pickerMaxMonth?: Date;
};
function Calendar({
className,
classNames,
showOutsideDays = true,
weekStartsOn = 1,
headerVariant = "default",
monthLabelClickable = true,
onMonthLabelClick,
locale,
formatters,
components,
pickerMinMonth,
pickerMaxMonth,
...props
}: CalendarProps) {
const defaultClassNames = getDefaultClassNames();
const [pickerOpen, setPickerOpen] = React.useState(false);
const config = React.useMemo<CalendarConfig>(
() => ({
headerVariant,
monthLabelClickable,
onMonthLabelClick,
locale,
pickerOpen,
setPickerOpen,
pickerMinMonth,
pickerMaxMonth,
}),
[
headerVariant,
monthLabelClickable,
onMonthLabelClick,
locale,
pickerOpen,
pickerMinMonth,
pickerMaxMonth,
],
);
return (
<CalendarConfigContext value={config}>
<DayPicker
showOutsideDays={showOutsideDays}
weekStartsOn={weekStartsOn}
locale={locale}
className={cn("group/calendar w-fit bg-bg-main px-6 py-5", className)}
formatters={{
formatWeekdayName: (date) =>
date.toLocaleString(locale?.code, { weekday: "short" }).slice(0, 2),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("flex flex-col", defaultClassNames.months),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
// The header is rendered by MonthCaption — react-day-picker's own nav
// row would duplicate it.
nav: cn("hidden", defaultClassNames.nav),
month_caption: cn("w-full", defaultClassNames.month_caption),
month_grid: cn(
"w-full border-collapse",
defaultClassNames.month_grid,
),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex size-10 items-center justify-center text-sm leading-[1.4] font-normal text-text-note select-none",
defaultClassNames.weekday,
),
week: cn("flex w-full", defaultClassNames.week),
day: cn("group/day relative p-0 text-center", defaultClassNames.day),
// The range band is painted behind the endpoint disc — half a cell on
// the inner side of each endpoint.
range_start: cn(
"relative isolate z-0 after:absolute after:inset-y-0 after:right-0 after:-z-10 after:w-5 after:bg-bg-surface-1",
defaultClassNames.range_start,
),
range_end: cn(
"relative isolate z-0 after:absolute after:inset-y-0 after:left-0 after:-z-10 after:w-5 after:bg-bg-surface-1",
defaultClassNames.range_end,
),
range_middle: cn(
"relative isolate z-0",
defaultClassNames.range_middle,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...rootProps }) => (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...rootProps}
/>
),
MonthCaption: ({ calendarMonth }) => (
<CalendarCaption month={calendarMonth.date} />
),
Chevron: ({ className, orientation, ...chevronProps }) => {
if (orientation === "left") {
return (
<ChevronLeft
className={cn("size-4", className)}
{...chevronProps}
/>
);
}
if (orientation === "right") {
return (
<ChevronRight
className={cn("size-4", className)}
{...chevronProps}
/>
);
}
return (
<ChevronDown
className={cn("size-4", className)}
{...chevronProps}
/>
);
},
DayButton: ({ className, day, modifiers, ...dayProps }) => {
const isEndpoint = !!modifiers.selected && !modifiers.range_middle;
return (
<CalendarCell
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected={isEndpoint || undefined}
data-range-start={modifiers.range_start || undefined}
data-range-end={modifiers.range_end || undefined}
data-range-middle={modifiers.range_middle || undefined}
data-today={
(modifiers.today && !modifiers.selected) || undefined
}
data-active={modifiers.focused || undefined}
data-outside={modifiers.outside || undefined}
className={cn(className)}
{...dayProps}
/>
);
},
...components,
}}
{...props}
/>
</CalendarConfigContext>
);
}
/* -------------------------------------------------------------------------- */
/* Calendar range */
/* -------------------------------------------------------------------------- */
export type CalendarRangeProps = Omit<
React.ComponentProps<"div">,
"onSelect"
> & {
selected?: DateRange;
onSelect?: (range: DateRange | undefined) => void;
/** Month the left panel opens on; the right opens a month later. */
defaultMonth?: Date;
startMonth?: Date;
endMonth?: Date;
disabled?: Matcher | Matcher[];
locale?: Partial<Locale>;
showOutsideDays?: boolean;
weekStartsOn?: React.ComponentProps<typeof Calendar>["weekStartsOn"];
monthLabelClickable?: boolean;
};
/** First of the month — the two panels compare and store months, never days. */
function startOfMonth(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
function addMonths(date: Date, amount: number) {
return new Date(date.getFullYear(), date.getMonth() + amount, 1);
}
/**
* The design's dual-date layout: two panels split by a rule, the left carrying
* only a previous arrow and the right only a next one.
*
* The panels hold their months INDEPENDENTLY — this is not one DayPicker
* showing two months, because that forces them consecutive. Independence is
* what makes `from <= to` violable, so each panel's month & year picker is
* bounded by the other's month: the left can never pass the right, and the
* right can never fall behind the left.
*/
function CalendarRange({
className,
selected,
onSelect,
defaultMonth,
startMonth,
endMonth,
disabled,
locale,
showOutsideDays,
weekStartsOn,
monthLabelClickable,
...props
}: CalendarRangeProps) {
const [fromMonth, setFromMonth] = React.useState(() =>
startOfMonth(defaultMonth ?? selected?.from ?? new Date()),
);
const [toMonth, setToMonth] = React.useState(() =>
addMonths(defaultMonth ?? selected?.from ?? new Date(), 1),
);
// The bound is `from <= to`, so both panels are allowed to rest on the SAME
// month. The arrows can only widen the pair — the left has no next and the
// right has no previous — so they cannot cross on their own. The pushes below
// are the backstop for anything that sets a month directly.
const handleFromMonth = (month: Date) => {
const next = startOfMonth(month);
setFromMonth(next);
setToMonth((to) => (to < next ? next : to));
};
const handleToMonth = (month: Date) => {
const next = startOfMonth(month);
setToMonth(next);
setFromMonth((from) => (from > next ? next : from));
};
const shared = {
mode: "range",
selected,
onSelect,
startMonth,
endMonth,
disabled,
locale,
showOutsideDays,
weekStartsOn,
monthLabelClickable,
} as const;
return (
<div
data-slot="calendar-range"
className={cn("flex divide-x divide-border-default", className)}
{...props}
>
<Calendar
{...shared}
headerVariant="first-in-range"
month={fromMonth}
onMonthChange={handleFromMonth}
pickerMaxMonth={toMonth}
/>
<Calendar
{...shared}
headerVariant="last-in-range"
month={toMonth}
onMonthChange={handleToMonth}
pickerMinMonth={fromMonth}
/>
</div>
);
}
export {
Calendar,
CalendarCell,
CalendarMonthYearPicker,
CalendarRange,
CalendarPeriodHeader,
CalendarPeriodIndicator,
CalendarPeriodNav,
CalendarPeriodNavButton,
calendarPeriodHeaderVariants,
calendarPeriodIndicatorVariants,
};