Calendar
A month grid with single, range and multiple selection, a direction-aware month slide, lit-key selected days and a full WAI-ARIA keyboard grid.
Preview
November 2026 – December 2026
November 2026
SunMonTueWedThuFriSat
December 2026
SunMonTueWedThuFriSat
Nov 12 → Nov 186 nights
November 2026
November 2026
WkMonTueWedThuFriSatSun
44
45
46
47
48
49
Review on Nov 19
November 2026
November 2026
SunMonTueWedThuFriSat
4 on-call shifts
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/calendar.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| mode | "single" | "range" | "multiple" | "single" | Selection model: one day, a from–to range, or a set of days. |
| selected | Date | DateRange | Date[] | — | Controlled selection (typed by mode). Present-but-undefined clears it. |
| defaultSelected | Date | DateRange | Date[] | — | Initial selection when uncontrolled. |
| onSelect | (value) => void | — | Called with the new selection for the current mode. |
| required | boolean | false | Clicking the selected day does not clear it. |
| month / defaultMonth | Date | — | Controlled or initial first visible month. |
| onMonthChange | (month: Date) => void | — | Called with the first day of the newly visible month. |
| numberOfMonths | 1 | 2 | 1 | Months shown side by side. |
| min / max | Date | — | Selectable bounds; navigation stops at their months. |
| disabled | Date[] | (date: Date) => boolean | — | Blocked days: faded, struck and not selectable. |
| showOutsideDays | boolean | true | Show muted days from neighbouring months. |
| showWeekNumber | boolean | false | Add a column of ISO week numbers. |
| weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 0 | First weekday column; 0 is Sunday. |
| today | Date | — | Day marked as today. Omitted, it is read on the client after mount. |
| locale | string | "en-US" | Intl locale for captions, weekdays and full-date labels. |
| autoFocus | boolean | false | Focus the tab-stop day on mount (for popovers). |
| footer | ReactNode | — | Summary line or actions under the grid. |
Build notes
- Dates are local-midnight `Date`s; helpers at the top of the file do day
maths (`addMonths` clamps the day). `toDayKey` (`yyyy-mm-dd`) is the React
key and `data-day`.
- `getWeeks` starts at the first day of the month minus
`(firstDay - weekStartsOn + 7) % 7` and always returns 6×7 days. ISO week
numbers use the Thursday in each row.
- Month slide: the months strip is one `motion.div` keyed by the first
visible month inside `AnimatePresence mode="popLayout" initial={false}`,
with `custom={direction}` variants (enter x = dir × 28 px, exit
x = −dir × 28 px), x on the house spring (420/32/0.8) and opacity 160 ms.
The clip box has `overflow-hidden` with 6 px padding and matching negative
margin so focus rings at the edge are not cut. Direction is derived with
the "adjust state while rendering" pattern by comparing the month index to
the previous one, so controlled `month` animates too.
- Focus after a keyboard move: set `focused`, flag `pendingFocus`, and an
effect focuses `[data-strip=<current key>] button[data-day=<key>]`; the
strip key keeps the exiting copy (which may contain the same date) out of
the query.
- Range band: an absolutely positioned span behind the day button inside the
gridcell: `start-1/2 end-0` on the start day, `start-0 end-1/2` on the end,
`inset-x-0` between, `rounded-s-lg` on column 0 / day 1 and `rounded-e-lg`
on column 6 / last day. Committed band `bg-accent/16` (20% dark); preview
`bg-accent/10`.
- The lit key is a separate span mounted only while selected, with
`starting:scale-75 starting:opacity-0` and a `scale,opacity` transition,
so `@starting-style` pops it in on each new selection without JS.
- Today without a prop uses `useSyncExternalStore` with an undefined server
snapshot, so SSR and hydration agree and the dot appears after mount. If no
month can be resolved yet, the strip holds its space with an empty box.
- Selection is controlled when `selected` is present in props (checked with
`hasOwnProperty`), so a parent can clear it with `selected={undefined}`.
<!-- PEBBLE-LICENSE -->View source
registry/data/calendar/calendar.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: calendar.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THIRD-PARTY NOTICES
These notices apply to identified upstream portions; Pebble terms do not replace or narrow their rights.
Upstream: https://raw.githubusercontent.com/shadcn-ui/ui/main/LICENSE.md
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* Date helpers (local time, midnight-based). Exported for the date picker. */
/* -------------------------------------------------------------------------- */
export function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
export function startOfMonth(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
export function addDays(date: Date, days: number) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
}
/** Adds months and clamps the day, so Jan 31 + 1 month is Feb 28/29. */
export function addMonths(date: Date, months: number) {
const target = new Date(date.getFullYear(), date.getMonth() + months, 1);
const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
return new Date(target.getFullYear(), target.getMonth(), Math.min(date.getDate(), last));
}
export function isSameDay(a: Date | undefined, b: Date | undefined) {
return (
!!a &&
!!b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function isSameMonth(a: Date, b: Date) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
}
/** Whole-day comparison: negative when a is before b. */
export function compareDays(a: Date, b: Date) {
return startOfDay(a).getTime() - startOfDay(b).getTime();
}
function monthIndex(date: Date) {
return date.getFullYear() * 12 + date.getMonth();
}
/** `yyyy-mm-dd` in local time; used as keys and `data-day`. */
export function toDayKey(date: Date) {
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${m}-${d}`;
}
function fromDayKey(key: string) {
const [y, m, d] = key.split("-").map(Number);
return new Date(y, m - 1, d);
}
/** ISO-8601 week number of the week containing `date`. */
function isoWeek(date: Date) {
const target = startOfDay(date);
target.setDate(target.getDate() + 3 - ((target.getDay() + 6) % 7));
const jan4 = new Date(target.getFullYear(), 0, 4);
return (
1 +
Math.round(((target.getTime() - jan4.getTime()) / 864e5 - 3 + ((jan4.getDay() + 6) % 7)) / 7)
);
}
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export type DateRange = { from: Date | undefined; to?: Date | undefined };
/** Dates to disable: a list, or a test that returns true for disabled days. */
export type DateMatcher = Date[] | ((date: Date) => boolean);
type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
type CalendarBaseProps = Omit<React.ComponentProps<"div">, "onSelect" | "defaultValue"> & {
/** Controlled first visible month. */
month?: Date;
/** Initial first visible month when uncontrolled. */
defaultMonth?: Date;
onMonthChange?: (month: Date) => void;
/** One or two months side by side. */
numberOfMonths?: 1 | 2;
/** Earliest selectable day; navigation stops at its month. */
min?: Date;
/** Latest selectable day; navigation stops at its month. */
max?: Date;
disabled?: DateMatcher;
/** Show the muted trailing and leading days of neighbour months. */
showOutsideDays?: boolean;
/** Add a column of ISO week numbers. */
showWeekNumber?: boolean;
/** 0 = Sunday … 6 = Saturday. */
weekStartsOn?: Weekday;
/** The day to mark as today. Omitted, it is read on the client after mount. */
today?: Date;
/** BCP 47 locale for month, weekday and full-date labels. */
locale?: string;
/** Move focus into the grid on mount (use inside popovers). */
autoFocus?: boolean;
/** Content under the grid: a summary line or actions. */
footer?: React.ReactNode;
};
export type CalendarSingleProps = CalendarBaseProps & {
mode?: "single";
selected?: Date | undefined;
defaultSelected?: Date;
onSelect?: (date: Date | undefined) => void;
/** Keep a selection: clicking the selected day does not clear it. */
required?: boolean;
};
export type CalendarRangeProps = CalendarBaseProps & {
mode: "range";
selected?: DateRange | undefined;
defaultSelected?: DateRange;
onSelect?: (range: DateRange | undefined) => void;
required?: boolean;
};
export type CalendarMultipleProps = CalendarBaseProps & {
mode: "multiple";
selected?: Date[] | undefined;
defaultSelected?: Date[];
onSelect?: (dates: Date[]) => void;
required?: boolean;
};
export type CalendarProps = CalendarSingleProps | CalendarRangeProps | CalendarMultipleProps;
type Selection = Date | DateRange | Date[] | undefined;
const SPRING = { type: "spring", stiffness: 420, damping: 32, mass: 0.8 } as const;
const SLIDE = 28;
const slide = {
enter: (dir: number) => ({ x: dir * SLIDE, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir * -SLIDE, opacity: 0 }),
};
// Read today on the client only: the server snapshot is undefined, so
// server and hydration markup agree and the marker appears right after.
const noopSubscribe = () => () => {};
const readTodayKey = () => toDayKey(new Date());
const readServerToday = () => undefined;
function useFormatters(locale: string) {
return React.useMemo(
() => ({
caption: new Intl.DateTimeFormat(locale, { month: "long" }),
year: new Intl.DateTimeFormat(locale, { year: "numeric" }),
monthYear: new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
weekday: new Intl.DateTimeFormat(locale, { weekday: "short" }),
weekdayLong: new Intl.DateTimeFormat(locale, { weekday: "long" }),
day: new Intl.DateTimeFormat(locale, { day: "numeric" }),
full: new Intl.DateTimeFormat(locale, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
}),
week: new Intl.NumberFormat(locale),
}),
[locale],
);
}
function getWeeks(month: Date, weekStartsOn: Weekday) {
const first = startOfMonth(month);
const start = addDays(first, -((first.getDay() - weekStartsOn + 7) % 7));
// Always six rows, so two-month layouts line up and height never jumps
// while sliding between a 5-week and a 6-week month.
return Array.from({ length: 6 }, (_, w) =>
Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),
);
}
function ChevronIcon({ direction }: { direction: "left" | "right" }) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="size-4 rtl:-scale-x-100"
>
<path
d={direction === "left" ? "M9.75 3.75 5.5 8l4.25 4.25" : "M6.25 3.75 10.5 8l-4.25 4.25"}
/>
</svg>
);
}
export function Calendar(props: CalendarProps) {
const {
mode = "single",
selected: selectedProp,
defaultSelected,
onSelect,
required = false,
month: monthProp,
defaultMonth,
onMonthChange,
numberOfMonths = 1,
min,
max,
disabled,
showOutsideDays = true,
showWeekNumber = false,
weekStartsOn = 0,
today: todayProp,
locale = "en-US",
autoFocus = false,
footer,
className,
...rest
} = props;
const fmt = useFormatters(locale);
const labelId = React.useId();
const stripRef = React.useRef<HTMLDivElement>(null);
/* ----- selection (controlled when `selected` is passed, even undefined) ---- */
const controlled = Object.prototype.hasOwnProperty.call(props, "selected");
const [internal, setInternal] = React.useState<Selection>(defaultSelected);
const selection: Selection = controlled ? selectedProp : internal;
const single = mode === "single" ? (selection as Date | undefined) : undefined;
const range = mode === "range" ? (selection as DateRange | undefined) : undefined;
const multiple = mode === "multiple" ? ((selection as Date[] | undefined) ?? []) : [];
/* ----- today ----- */
const clientTodayKey = React.useSyncExternalStore(noopSubscribe, readTodayKey, readServerToday);
const today = todayProp
? startOfDay(todayProp)
: clientTodayKey
? fromDayKey(clientTodayKey)
: undefined;
/* ----- visible month ----- */
const firstSelected = single ?? range?.from ?? multiple[0];
const [monthState, setMonthState] = React.useState<Date | undefined>(() => {
const seed = defaultMonth ?? firstSelected ?? todayProp;
return seed ? startOfMonth(seed) : undefined;
});
const fallbackMonth = today ? startOfMonth(today) : undefined;
const displayMonth = monthProp ? startOfMonth(monthProp) : (monthState ?? fallbackMonth);
const displayIndex = displayMonth ? monthIndex(displayMonth) : 0;
// Direction of the last month change, kept with the "adjust state while
// rendering" pattern so controlled months animate the right way too.
const [prevIndex, setPrevIndex] = React.useState(displayIndex);
const [direction, setDirection] = React.useState(0);
if (prevIndex !== displayIndex) {
setPrevIndex(displayIndex);
setDirection(displayIndex > prevIndex ? 1 : -1);
}
const months = displayMonth
? Array.from({ length: numberOfMonths }, (_, i) => addMonths(displayMonth, i))
: [];
const minDay = min ? startOfDay(min) : undefined;
const maxDay = max ? startOfDay(max) : undefined;
const canGoBack = !!displayMonth && (!minDay || monthIndex(displayMonth) > monthIndex(minDay));
const canGoForward =
!!displayMonth &&
(!maxDay || monthIndex(displayMonth) + numberOfMonths - 1 < monthIndex(maxDay));
const isDisabled = (date: Date) => {
if (minDay && compareDays(date, minDay) < 0) return true;
if (maxDay && compareDays(date, maxDay) > 0) return true;
if (!disabled) return false;
return Array.isArray(disabled) ? disabled.some((d) => isSameDay(d, date)) : disabled(date);
};
/* ----- focus (roving tabindex) ----- */
const [focused, setFocused] = React.useState<Date | undefined>(undefined);
const [hovered, setHovered] = React.useState<Date | undefined>(undefined);
const pendingFocus = React.useRef(autoFocus);
const inView = (date: Date) => months.some((m) => isSameMonth(m, date));
const clampDay = (date: Date) => {
if (minDay && compareDays(date, minDay) < 0) return minDay;
if (maxDay && compareDays(date, maxDay) > 0) return maxDay;
return date;
};
// The one tabbable day: the focused day if visible, else the selection,
// today, or the first enabled day of the first visible month.
const tabbable = (() => {
if (focused && inView(focused)) return focused;
const candidates = [single, range?.from, ...multiple, today];
for (const c of candidates) if (c && inView(c) && !isDisabled(c)) return c;
for (const m of months) {
const last = new Date(m.getFullYear(), m.getMonth() + 1, 0).getDate();
for (let d = 1; d <= last; d++) {
const day = new Date(m.getFullYear(), m.getMonth(), d);
if (!isDisabled(day)) return day;
}
}
return months[0];
})();
const stripKey = displayMonth ? toDayKey(displayMonth) : "empty";
const tabbableKey = tabbable ? toDayKey(tabbable) : "";
React.useEffect(() => {
if (!pendingFocus.current || !tabbableKey) return;
const button = stripRef.current?.querySelector<HTMLButtonElement>(
`[data-strip="${stripKey}"] button[data-day="${tabbableKey}"]`,
);
if (button) {
pendingFocus.current = false;
button.focus({ preventScroll: true });
}
}, [stripKey, tabbableKey]);
const changeMonth = (next: Date) => {
const m = startOfMonth(next);
if (!monthProp) setMonthState(m);
onMonthChange?.(m);
};
const navigate = (step: number) => {
if (!displayMonth) return;
changeMonth(addMonths(displayMonth, step));
// Keep the roving stop on the same day number in the new view.
if (focused) setFocused(clampDay(addMonths(focused, step)));
};
const moveFocus = (next: Date) => {
const target = clampDay(next);
setFocused(target);
if (mode === "range" && range?.from && !range.to) setHovered(target);
pendingFocus.current = true;
if (!displayMonth || inView(target)) return;
const forward = monthIndex(target) > monthIndex(displayMonth);
changeMonth(forward ? addMonths(startOfMonth(target), -(numberOfMonths - 1)) : target);
};
/* ----- selection logic ----- */
const select = (date: Date) => {
if (isDisabled(date)) return;
let next: Selection;
if (mode === "single") {
next = isSameDay(single, date) && !required ? undefined : date;
setInternal(next);
(onSelect as CalendarSingleProps["onSelect"])?.(next as Date | undefined);
} else if (mode === "multiple") {
const has = multiple.some((d) => isSameDay(d, date));
const list =
has && !(required && multiple.length === 1)
? multiple.filter((d) => !isSameDay(d, date))
: has
? multiple
: [...multiple, date].sort((a, b) => a.getTime() - b.getTime());
setInternal(list);
(onSelect as CalendarMultipleProps["onSelect"])?.(list);
} else {
const from = range?.from;
const to = range?.to;
let r: DateRange | undefined;
if (!from || to) r = { from: date, to: undefined };
else if (compareDays(date, from) < 0) r = { from: date, to: from };
else r = { from, to: date };
setInternal(r);
setHovered(undefined);
(onSelect as CalendarRangeProps["onSelect"])?.(r);
}
setFocused(date);
};
const onDayKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, date: Date) => {
const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
const offset = (date.getDay() - weekStartsOn + 7) % 7;
let next: Date | undefined;
switch (event.key) {
case "ArrowLeft":
next = addDays(date, rtl ? 1 : -1);
break;
case "ArrowRight":
next = addDays(date, rtl ? -1 : 1);
break;
case "ArrowUp":
next = addDays(date, -7);
break;
case "ArrowDown":
next = addDays(date, 7);
break;
case "Home":
next = addDays(date, -offset);
break;
case "End":
next = addDays(date, 6 - offset);
break;
case "PageUp":
next = addMonths(date, event.shiftKey ? -12 : -1);
break;
case "PageDown":
next = addMonths(date, event.shiftKey ? 12 : 1);
break;
default:
return;
}
event.preventDefault();
moveFocus(next);
};
/* ----- range preview ----- */
const rangeEnd = range?.to ?? (range?.from && hovered ? hovered : undefined);
const [bandFrom, bandTo] =
range?.from && rangeEnd
? compareDays(rangeEnd, range.from) < 0
? [rangeEnd, range.from]
: [range.from, rangeEnd]
: [undefined, undefined];
const previewing = !!range?.from && !range.to;
const weekdays = months[0] ? getWeeks(months[0], weekStartsOn)[0] : [];
const columns = showWeekNumber
? "grid-cols-[1.75rem_repeat(7,var(--cell))]"
: "grid-cols-[repeat(7,var(--cell))]";
const liveLabel =
months.length === 0
? ""
: months.length === 1
? fmt.monthYear.format(months[0])
: `${fmt.monthYear.format(months[0])} – ${fmt.monthYear.format(months[months.length - 1])}`;
return (
<MotionConfig reducedMotion="user">
<div
data-slot="calendar"
data-mode={mode}
className={cn(
"relative w-fit rounded-xl bg-card p-3 text-sm text-card-foreground [--cell:--spacing(8)] sm:[--cell:--spacing(9)]",
"shadow-raised-md ring-1 inset-shadow-highlight ring-hairline",
// Inside a popover the popover is the surface.
"[[data-slot=popover-content]_&]:bg-transparent [[data-slot=popover-content]_&]:p-0 [[data-slot=popover-content]_&]:shadow-none [[data-slot=popover-content]_&]:ring-0 [[data-slot=popover-content]_&]:inset-shadow-none",
className,
)}
{...rest}
onMouseLeave={(event) => {
setHovered(undefined);
rest.onMouseLeave?.(event);
}}
>
<span className="sr-only" aria-live="polite" aria-atomic>
{liveLabel}
</span>
<div
data-slot="calendar-nav"
className="absolute end-3 top-3 z-10 flex items-center gap-1 [[data-slot=popover-content]_&]:end-0 [[data-slot=popover-content]_&]:top-0"
>
{(["left", "right"] as const).map((side) => {
const back = side === "left";
const enabled = back ? canGoBack : canGoForward;
return (
<button
key={side}
type="button"
data-slot={back ? "calendar-previous" : "calendar-next"}
aria-label={back ? "Previous month" : "Next month"}
disabled={!enabled}
onClick={() => navigate(back ? -1 : 1)}
className={cn(
"grid size-7 cursor-pointer place-items-center rounded-md bg-card text-muted-foreground",
"shadow-raised-xs ring-1 inset-shadow-highlight ring-hairline",
"transition-[background-color,color,transform,box-shadow] duration-(--motion-fast) ease-enter",
"hover:bg-muted hover:text-foreground active:translate-y-px active:scale-[0.94] active:shadow-none active:inset-shadow-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
"disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none motion-reduce:active:scale-100",
)}
>
<ChevronIcon direction={side} />
</button>
);
})}
</div>
{/* Clip box for the slide; padded so edge focus rings are not cut. */}
<div ref={stripRef} className="relative -m-1.5 overflow-hidden p-1.5">
<AnimatePresence initial={false} mode="popLayout" custom={direction}>
<motion.div
key={stripKey}
data-strip={stripKey}
custom={direction}
variants={slide}
initial="enter"
animate="center"
exit="exit"
transition={{ x: SPRING, opacity: { duration: 0.16 } }}
// Two months stack on narrow screens instead of overflowing.
className="flex flex-wrap gap-6"
>
{months.length === 0 && (
// Nothing to anchor on yet (no month, selection or today
// before mount): hold the space so the layout does not jump.
<div
aria-hidden
className="h-[calc(var(--cell)*7+2.75rem)] w-[calc(var(--cell)*7)]"
/>
)}
{months.map((month, mi) => {
const captionId = `${labelId}-m${mi}`;
return (
<div
key={toDayKey(month)}
data-slot="calendar-month"
className="flex flex-col gap-2"
>
<div className="flex h-7 items-center ps-1.5">
<h2
id={captionId}
data-slot="calendar-caption"
className="text-sm font-medium tracking-[-0.01em] text-foreground"
>
{fmt.caption.format(month)}{" "}
<span className="font-normal text-muted-foreground tabular-nums">
{fmt.year.format(month)}
</span>
</h2>
</div>
<div role="grid" aria-labelledby={captionId} className="flex flex-col gap-0.5">
<div role="row" className={cn("grid", columns)}>
{showWeekNumber && (
<span
role="columnheader"
aria-label="Week"
className="grid h-7 place-items-center text-[10px] font-medium text-muted-foreground"
>
Wk
</span>
)}
{weekdays.map((d) => (
<span
key={d.getDay()}
role="columnheader"
aria-label={fmt.weekdayLong.format(d)}
className="grid h-7 place-items-center text-[11px] font-medium tracking-wide text-muted-foreground uppercase"
>
{fmt.weekday.format(d)}
</span>
))}
</div>
{getWeeks(month, weekStartsOn).map((week) => {
const thursday = week.find((d) => d.getDay() === 4) ?? week[0];
return (
<div key={toDayKey(week[0])} role="row" className={cn("grid", columns)}>
{showWeekNumber && (
<span
role="rowheader"
aria-label={`Week ${isoWeek(thursday)}`}
className="grid h-(--cell) place-items-center font-mono text-[10px] text-muted-foreground tabular-nums"
>
{fmt.week.format(isoWeek(thursday))}
</span>
)}
{week.map((date, col) => {
const outside = !isSameMonth(date, month);
const key = toDayKey(date);
const dayDisabled = isDisabled(date);
const outOfBounds =
(!!minDay && compareDays(date, minDay) < 0) ||
(!!maxDay && compareDays(date, maxDay) > 0);
const isToday = isSameDay(date, today);
const isStart = !!bandFrom && isSameDay(date, bandFrom);
const isEnd = !!bandTo && isSameDay(date, bandTo);
const inBand =
!!bandFrom &&
!!bandTo &&
compareDays(date, bandFrom) >= 0 &&
compareDays(date, bandTo) <= 0;
const committedEdge =
!!range?.from &&
(isSameDay(date, range.from) || isSameDay(date, range.to));
const isSelected =
mode === "single"
? isSameDay(date, single)
: mode === "multiple"
? multiple.some((d) => isSameDay(d, date))
: committedEdge;
const middle = inBand && !isStart && !isEnd;
const showBand =
inBand &&
!isSameDay(bandFrom, bandTo) &&
(!outside || showOutsideDays);
if (outside) {
return (
<div
key={key}
role="gridcell"
className="relative grid h-(--cell) place-items-center"
>
{showBand && (
<span
aria-hidden
className={cn(
"absolute inset-y-0.5 bg-accent/8",
isStart
? "start-1/2 end-0"
: isEnd
? "start-0 end-1/2"
: "inset-x-0",
col === 0 && "rounded-s-lg",
col === 6 && "rounded-e-lg",
)}
/>
)}
{showOutsideDays && (
<span
aria-hidden
className="relative text-[13px] font-light text-muted-foreground tabular-nums"
>
{fmt.day.format(date)}
</span>
)}
</div>
);
}
const lastOfMonth =
date.getDate() ===
new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
return (
<div
key={key}
role="gridcell"
aria-selected={
isSelected ||
(mode === "range" && inBand && !previewing) ||
undefined
}
data-range={
showBand
? isStart
? "start"
: isEnd
? "end"
: "middle"
: undefined
}
className="relative grid h-(--cell) place-items-center"
>
{showBand && (
<span
aria-hidden
data-slot="calendar-range-band"
className={cn(
"absolute inset-y-0.5 transition-colors duration-(--motion-fast)",
previewing
? "bg-accent/10"
: "bg-accent/16 dark:bg-accent/20",
isStart
? "start-1/2 end-0"
: isEnd
? "start-0 end-1/2"
: "inset-x-0",
// Round the band where a row or the month breaks it.
(col === 0 || date.getDate() === 1) && "rounded-s-lg",
(col === 6 || lastOfMonth) && "rounded-e-lg",
)}
/>
)}
<button
type="button"
data-slot="calendar-day"
data-day={key}
data-today={isToday || undefined}
data-selected={isSelected || undefined}
data-preview-end={
(previewing && (isStart || isEnd) && !isSelected) || undefined
}
tabIndex={key === tabbableKey ? 0 : -1}
aria-label={fmt.full.format(date) + (isToday ? ", today" : "")}
aria-current={isToday ? "date" : undefined}
aria-disabled={dayDisabled || undefined}
onClick={() => select(date)}
onKeyDown={(e) => onDayKeyDown(e, date)}
onFocus={() => setFocused(date)}
onMouseEnter={() => previewing && setHovered(date)}
className={cn(
"relative grid size-(--cell) cursor-pointer place-items-center rounded-lg text-[13px] text-foreground tabular-nums select-none",
"transition-[background-color,color,box-shadow,transform] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
"focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card focus-visible:outline-none",
"active:scale-[0.92] motion-reduce:active:scale-100",
!isSelected && "hover:bg-muted",
middle && "font-medium hover:bg-accent/15",
isToday && !isSelected && "font-semibold",
// The lit key: the selected day is a pressed-in-light ink keycap.
isSelected && "font-medium text-primary-foreground",
!isSelected &&
previewing &&
(isStart || isEnd) &&
"ring-1 ring-accent/60 ring-inset",
dayDisabled &&
"cursor-not-allowed text-muted-foreground/50 hover:bg-transparent active:scale-100",
// Blocked days are struck; days outside min/max only fade.
dayDisabled &&
!outOfBounds &&
"line-through decoration-muted-foreground/40",
)}
>
{isSelected && (
// The lit key, mounted fresh on select so @starting-style
// pops it in (0.75 → 1) instead of snapping.
<span
aria-hidden
data-slot="calendar-key"
className={cn(
"absolute inset-0 rounded-lg bg-linear-to-b from-key-top to-key-bottom shadow-[var(--key-shine),var(--elevation-sm)] ring-1 ring-key-edge",
"transition-[scale,opacity] duration-(--motion-base) ease-enter motion-reduce:transition-none starting:scale-75 starting:opacity-0",
)}
/>
)}
<span className="relative">{fmt.day.format(date)}</span>
{isToday && (
<span
aria-hidden
data-slot="calendar-today"
className="absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-accent"
/>
)}
</button>
</div>
);
})}
</div>
);
})}
</div>
</div>
);
})}
</motion.div>
</AnimatePresence>
</div>
{footer && (
<div
data-slot="calendar-footer"
className="mt-3 border-t border-border pt-3 text-[13px] text-muted-foreground"
>
{footer}
</div>
)}
</div>
</MotionConfig>
);
}
