Date Picker
Month-grid calendar with a selection pill that glides between days, directional month slides, single, range, week or multiple-day selection across one or several side-by-side months, ISO week numbers, per-day labels, min/max and per-day disabling, full keyboard grid navigation, a date field, a two-month range field with presets, a booking calendar with nightly prices, time and date-time wheels and a month picker. Locale-aware, reduced-motion safe.
Date Picker
date-picker.tsxDate field whose calendar morphs open from the trigger corner and closes on pick.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/date-picker
import { CalendarDays } from "lucide-react";
import { useMemo, useState } from "react";
import { Calendar } from "@/components/motion/calendar";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { cn } from "@/lib/utils";
const MEDIUM_DATE: Intl.DateTimeFormatOptions = { dateStyle: "medium" };
export interface DatePickerProps {
value?: Date | null;
defaultValue?: Date | null;
onValueChange?: (date: Date) => void;
placeholder?: string;
/** Intl options for the trigger label. Default: medium date. */
formatOptions?: Intl.DateTimeFormatOptions;
/** Default "en-US". */
locale?: string;
minDate?: Date;
maxDate?: Date;
isDateDisabled?: (date: Date) => boolean;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
align?: "start" | "end";
disabled?: boolean;
className?: string;
"aria-label"?: string;
}
/**
* Date field whose calendar morphs open from the trigger corner and closes
* as soon as a day is picked. Controlled or uncontrolled.
*/
export function DatePicker({
value: controlledValue,
defaultValue = null,
onValueChange,
placeholder = "Pick a date",
formatOptions = MEDIUM_DATE,
locale = "en-US",
minDate,
maxDate,
isDateDisabled,
weekStartsOn,
align = "start",
disabled,
className,
"aria-label": ariaLabel = "Choose date",
}: DatePickerProps) {
const [open, setOpen] = useState(false);
const [internalValue, setInternalValue] = useState<Date | null>(
defaultValue,
);
const value =
controlledValue !== undefined ? controlledValue : internalValue;
const label = useMemo(
() =>
value
? new Intl.DateTimeFormat(locale, formatOptions).format(value)
: null,
[value, locale, formatOptions],
);
return (
<MorphPopover open={open} onOpenChange={setOpen}>
<MorphPopoverTrigger>
<button
type="button"
disabled={disabled}
aria-label={label ? `${ariaLabel}, ${label}` : ariaLabel}
className={cn(
"inline-flex h-10 min-w-48 items-center gap-2 rounded-xl border border-border bg-background px-3 text-left text-sm outline-none transition-colors hover:border-border-strong focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
label ? "text-foreground" : "text-muted-foreground",
className,
)}
>
<CalendarDays className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate tabular-nums">
{label ?? placeholder}
</span>
</button>
</MorphPopoverTrigger>
<MorphPopoverContent align={align} className="p-0">
<Calendar
value={value}
onValueChange={(d) => {
if (controlledValue === undefined) setInternalValue(d);
onValueChange?.(d);
setOpen(false);
}}
locale={locale}
minDate={minDate}
maxDate={maxDate}
isDateDisabled={isDateDisabled}
weekStartsOn={weekStartsOn}
className="border-0 bg-transparent"
/>
</MorphPopoverContent>
</MorphPopover>
);
}
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type DateRange = { from: Date; to?: Date };
/** A whole week row: `from` is its first day, `to` its seventh. */
export type WeekRange = { from: Date; to: Date };
/** Per-day extras from `getDayMeta`. */
export type CalendarDayMeta = {
/** Short text under the day number (a price, a count). Cells grow taller
* when any day has meta; the label is also read after the date. */
label?: string;
/** "accent" tints the label (deals, highlights); "muted" fades it. */
tone?: "muted" | "accent";
/** Blocks the day and strikes it through, like `isDateDisabled`. */
disabled?: boolean;
};
type CalendarBaseProps = {
/** Earliest selectable day (inclusive). */
minDate?: Date;
/** Latest selectable day (inclusive). */
maxDate?: Date;
/** Extra per-day rule, e.g. weekends or booked dates. */
isDateDisabled?: (date: Date) => boolean;
/** 0 = Sunday … 6 = Saturday. Default 0. */
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** BCP 47 locale for month, weekday and day labels. Default "en-US" — a
* fixed default keeps server and client output identical for hydration. */
locale?: string;
/** Controlled visible month (any day inside it). */
month?: Date;
/** Uncontrolled initial visible month. Default: the selection or today. */
defaultMonth?: Date;
onMonthChange?: (month: Date) => void;
/** Months shown side by side (stacked below `sm`). Default 1. With more
* than one, days outside each month are left blank so none repeats. */
numberOfMonths?: number;
/** Adds an ISO 8601 week-number column ("Wk") before each row. */
showWeekNumber?: boolean;
/** Per-day label, tone and blocking, e.g. nightly prices or booked days.
* Passing it switches the grid to taller cells that fit the label. */
getDayMeta?: (date: Date) => CalendarDayMeta | null | undefined;
className?: string;
"aria-label"?: string;
};
export type CalendarSingleProps = CalendarBaseProps & {
mode?: "single";
value?: Date | null;
defaultValue?: Date | null;
onValueChange?: (date: Date) => void;
};
export type CalendarRangeProps = CalendarBaseProps & {
mode: "range";
value?: DateRange | null;
defaultValue?: DateRange | null;
onValueChange?: (range: DateRange) => void;
/** Fewest nights (days between start and end) a finished range may span.
* While the end is being chosen, days too close are blocked. */
minNights?: number;
/** Most nights a finished range may span; farther days are blocked. */
maxNights?: number;
};
export type CalendarWeekProps = CalendarBaseProps & {
mode: "week";
value?: WeekRange | null;
defaultValue?: WeekRange | null;
/** Called with the picked day's whole row, honouring `weekStartsOn`. */
onValueChange?: (week: WeekRange) => void;
};
export type CalendarMultipleProps = CalendarBaseProps & {
mode: "multiple";
value?: Date[] | null;
defaultValue?: Date[] | null;
/** Called with the selection sorted by date. */
onValueChange?: (dates: Date[]) => void;
/** Most days that can be picked; further picks are ignored. */
max?: number;
};
export type CalendarProps =
| CalendarSingleProps
| CalendarRangeProps
| CalendarWeekProps
| CalendarMultipleProps;
// ── date helpers (local time, day precision) ──────────────────────────────
const startOfDay = (d: Date) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate());
const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
const addDays = (d: Date, n: number) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
/** Adds months, clamping the day so Jan 31 + 1 month lands on Feb 28/29. */
function addMonths(d: Date, n: number) {
const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
const lastDay = new Date(
target.getFullYear(),
target.getMonth() + 1,
0,
).getDate();
target.setDate(Math.min(d.getDate(), lastDay));
return target;
}
const sameDay = (a?: Date | null, b?: Date | null) =>
!!a &&
!!b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
const sameMonth = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
const dayKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const monthKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
/** Whole days from `a` to `b`; rounding absorbs DST hour shifts. */
const daysBetween = (a: Date, b: Date) =>
Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / 86_400_000);
/** ISO 8601 week number: weeks start Monday, week 1 holds Jan 4. */
function isoWeek(d: Date) {
const thursday = addDays(d, 3 - ((d.getDay() + 6) % 7));
const jan4 = new Date(thursday.getFullYear(), 0, 4);
const firstThursday = addDays(jan4, 3 - ((jan4.getDay() + 6) % 7));
return 1 + Math.round(daysBetween(firstThursday, thursday) / 7);
}
/** Always 6 weeks, so the panel never changes height between months. */
function buildWeeks(month: Date, weekStartsOn: number) {
const first = startOfMonth(month);
const offset = (first.getDay() - weekStartsOn + 7) % 7;
const start = addDays(first, -offset);
return Array.from({ length: 6 }, (_, w) =>
Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),
);
}
// Month swap is a short directional slide; the exit is quicker than the entry.
const MONTH_ENTER = { duration: 0.26, ease: EASE_OUT } as const;
const MONTH_EXIT = { duration: 0.16, ease: EASE_OUT } as const;
/**
* Month-grid calendar for single dates, ranges, whole weeks or several
* separate days, one or more months wide. The selected day is a pill that
* glides between days on a shared-layout spring; a picked week is one pill
* across its row; months slide in from the direction you travel. Optional ISO
* week numbers, per-day labels (`getDayMeta`) and night limits for ranges.
* Full keyboard grid navigation (arrows, Home/End, PageUp/PageDown,
* Shift+Page for years). Controlled or uncontrolled for both the selection
* and the visible month; reduced-motion safe.
*/
export function Calendar(props: CalendarProps) {
const {
minDate,
maxDate,
isDateDisabled,
weekStartsOn = 0,
locale = "en-US",
month: controlledMonth,
defaultMonth,
onMonthChange,
numberOfMonths = 1,
showWeekNumber = false,
getDayMeta,
className,
"aria-label": ariaLabel,
} = props;
const isRange = props.mode === "range";
const isWeek = props.mode === "week";
const isMultiple = props.mode === "multiple";
const count = Math.max(1, Math.floor(numberOfMonths));
const minNights = isRange ? ((props as CalendarRangeProps).minNights ?? 0) : 0;
const maxNights = isRange ? (props as CalendarRangeProps).maxNights : undefined;
// Taller cells only when there is something to put under the number.
const tall = !!getDayMeta;
const uid = useId();
const reduce = useReducedMotion();
// ── selection ──
const [internalValue, setInternalValue] = useState<
Date | DateRange | WeekRange | Date[] | null
>(props.defaultValue ?? null);
const valueControlled = props.value !== undefined;
const value = valueControlled ? (props.value ?? null) : internalValue;
const single =
isRange || isWeek || isMultiple ? null : (value as Date | null);
const range = isRange ? (value as DateRange | null) : null;
const week = isWeek ? (value as WeekRange | null) : null;
const picked = isMultiple ? ((value as Date[] | null) ?? []) : [];
const isPicked = (d: Date) => picked.some((p) => sameDay(p, d));
// ── visible months ──
const anchor = single ?? range?.from ?? week?.from ?? picked[0] ?? null;
const [internalMonth, setInternalMonth] = useState(() =>
startOfMonth(defaultMonth ?? anchor ?? new Date()),
);
const month = controlledMonth
? startOfMonth(controlledMonth)
: internalMonth;
const [direction, setDirection] = useState(1);
const lastMonth = addMonths(month, count - 1);
const inWindow = (d: Date) => {
const m = startOfMonth(d);
return m >= month && m <= lastMonth;
};
const setMonth = useCallback(
(next: Date) => {
const m = startOfMonth(next);
if (sameMonth(m, month)) return;
setDirection(m > month ? 1 : -1);
if (!controlledMonth) setInternalMonth(m);
onMonthChange?.(m);
},
[month, controlledMonth, onMonthChange],
);
// "Today" is read after mount: the server's clock and timezone can disagree
// with the viewer's, and a mismatched marker would break hydration.
const [today, setToday] = useState<Date | null>(null);
useEffect(() => setToday(startOfDay(new Date())), []);
// ── roving focus ──
const [focused, setFocused] = useState<Date>(() =>
startOfDay(anchor ?? defaultMonth ?? new Date()),
);
const gridRef = useRef<HTMLDivElement>(null);
const shouldFocus = useRef(false);
// Range hover preview: the band follows the pointer until the end is picked.
// In week mode it is the row under the pointer or keyboard focus.
const [hovered, setHovered] = useState<Date | null>(null);
// Keep the tab stop inside the visible months when they change by buttons.
const focusInMonth = inWindow(focused)
? focused
: anchor && inWindow(anchor)
? anchor
: month;
useEffect(() => {
if (!shouldFocus.current) return;
shouldFocus.current = false;
gridRef.current
?.querySelector<HTMLButtonElement>(
`[data-month="${monthKey(focusInMonth)}"] [data-date="${dayKey(focusInMonth)}"]`,
)
?.focus();
}, [focusInMonth]);
const isDisabled = useCallback(
(d: Date) =>
(minDate && d < startOfDay(minDate)) ||
(maxDate && d > startOfDay(maxDate)) ||
!!isDateDisabled?.(d) ||
!!getDayMeta?.(d)?.disabled,
[minDate, maxDate, isDateDisabled, getDayMeta],
);
// While a range end is being chosen, days that would make it too short or
// too long are blocked (earlier days stay open: they restart the range).
const choosingEnd = !!range && !range.to;
const nightsAllowed = (n: number) =>
n >= minNights && (maxNights === undefined || n <= maxNights);
const blockedByNights = (d: Date) =>
choosingEnd &&
!!range &&
d > range.from &&
!nightsAllowed(daysBetween(range.from, d));
const weekStart = (d: Date) =>
addDays(d, -((d.getDay() - weekStartsOn + 7) % 7));
const select = (d: Date) => {
if (isDisabled(d) || blockedByNights(d)) return;
if (!inWindow(d)) setMonth(d);
setFocused(d);
if (isWeek) {
const from = weekStart(d);
const next: WeekRange = { from, to: addDays(from, 6) };
if (!valueControlled) setInternalValue(next);
(props as CalendarWeekProps).onValueChange?.(next);
return;
}
if (isMultiple) {
const max = (props as CalendarMultipleProps).max;
const next = isPicked(d)
? picked.filter((p) => !sameDay(p, d))
: max !== undefined && picked.length >= max
? null
: [...picked, d].sort((a, b) => a.getTime() - b.getTime());
if (!next) return;
if (!valueControlled) setInternalValue(next);
(props as CalendarMultipleProps).onValueChange?.(next);
return;
}
if (!isRange) {
if (!valueControlled) setInternalValue(d);
(props as CalendarSingleProps).onValueChange?.(d);
return;
}
// First click starts a range; a later day closes it; an earlier day, the
// start again when it would be too short, or a click on a finished range
// starts over.
const next: DateRange =
range &&
!range.to &&
d >= range.from &&
nightsAllowed(daysBetween(range.from, d))
? { from: range.from, to: d }
: { from: d };
if (!valueControlled) setInternalValue(next);
(props as CalendarRangeProps).onValueChange?.(next);
};
const onGridKeyDown = (e: KeyboardEvent) => {
const base = focusInMonth;
let next: Date | null = null;
switch (e.key) {
case "ArrowLeft":
next = addDays(base, -1);
break;
case "ArrowRight":
next = addDays(base, 1);
break;
case "ArrowUp":
next = addDays(base, -7);
break;
case "ArrowDown":
next = addDays(base, 7);
break;
case "Home":
next = addDays(base, -((base.getDay() - weekStartsOn + 7) % 7));
break;
case "End":
next = addDays(base, 6 - ((base.getDay() - weekStartsOn + 7) % 7));
break;
case "PageUp":
next = addMonths(base, e.shiftKey ? -12 : -1);
break;
case "PageDown":
next = addMonths(base, e.shiftKey ? 12 : 1);
break;
default:
return;
}
e.preventDefault();
shouldFocus.current = true;
setFocused(next);
// Scroll the window only when focus leaves it, by as little as needed.
if (!inWindow(next))
setMonth(next < month ? next : addMonths(startOfMonth(next), 1 - count));
};
// ── labels ──
const monthFormat = useMemo(
() => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
[locale],
);
const weekdays = useMemo(() => {
const short = new Intl.DateTimeFormat(locale, { weekday: "short" });
const long = new Intl.DateTimeFormat(locale, { weekday: "long" });
// 2023-01-01 was a Sunday.
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7));
return { short: short.format(d).slice(0, 2), long: long.format(d) };
});
}, [locale, weekStartsOn]);
const dayLabel = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
}),
[locale],
);
const prevDisabled =
!!minDate && startOfMonth(minDate) >= month;
const nextDisabled =
!!maxDate && startOfMonth(maxDate) <= lastMonth;
// Range band end: the picked end, or the hovered day while choosing one.
// A day that can't close the range doesn't stretch the band to it.
const bandEnd =
range &&
!range.to &&
hovered &&
hovered > range.from &&
!isDisabled(hovered) &&
!blockedByNights(hovered)
? hovered
: range?.to;
// Week mode: the row being previewed (pointer or keyboard focus).
const previewWeek = isWeek && hovered ? weekStart(hovered) : null;
const navButton =
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40";
const months = Array.from({ length: count }, (_, i) => addMonths(month, i));
// Weekday index of the column, so connecting bands stop at the row ends.
const column = (d: Date) => (d.getDay() - weekStartsOn + 7) % 7;
const cellSize = tall ? "h-12 w-10" : "size-9";
return (
<div
className={cn(
"w-fit rounded-2xl border border-border bg-card p-3 text-foreground",
className,
)}
>
<div
ref={gridRef}
onPointerLeave={() => setHovered(null)}
className="flex flex-col gap-x-6 gap-y-4 sm:flex-row"
>
{months.map((m, i) => {
const mk = monthKey(m);
const weeks = buildWeeks(m, weekStartsOn);
const labelId = `${uid}-label-${i}`;
return (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: columns are positional; each keeps its own month transition.
key={i}
className="min-w-0"
>
<div className="mb-2 flex items-center justify-between gap-2 px-1">
{i === 0 ? (
<button
type="button"
aria-label="Previous month"
disabled={prevDisabled}
onClick={() => setMonth(addMonths(month, -1))}
className={navButton}
>
<ChevronLeft className="h-4 w-4" />
</button>
) : (
<span aria-hidden className="size-8" />
)}
<h2
id={labelId}
aria-live={i === 0 ? "polite" : undefined}
className="text-sm font-medium tabular-nums"
>
{monthFormat.format(m)}
</h2>
{i === count - 1 ? (
<button
type="button"
aria-label="Next month"
disabled={nextDisabled}
onClick={() => setMonth(addMonths(month, 1))}
className={navButton}
>
<ChevronRight className="h-4 w-4" />
</button>
) : (
<span aria-hidden className="size-8" />
)}
</div>
<div className="relative overflow-hidden">
<AnimatePresence
initial={false}
mode="popLayout"
custom={direction}
>
<motion.table
key={mk}
data-month={mk}
custom={direction}
aria-labelledby={ariaLabel ? undefined : labelId}
aria-label={ariaLabel ? `${ariaLabel}, ${monthFormat.format(m)}` : undefined}
onKeyDown={onGridKeyDown}
variants={{
enter: (dir: number) => ({
opacity: 0,
x: reduce ? 0 : dir * 28,
filter: reduce ? "blur(0px)" : "blur(4px)",
}),
center: {
opacity: 1,
x: 0,
filter: "blur(0px)",
transition: MONTH_ENTER,
},
exit: (dir: number) => ({
opacity: 0,
x: reduce ? 0 : dir * -28,
filter: reduce ? "blur(0px)" : "blur(4px)",
transition: MONTH_EXIT,
}),
}}
initial="enter"
animate="center"
exit="exit"
className="border-collapse"
>
<thead>
<tr>
{showWeekNumber && (
<th
scope="col"
abbr="Week"
className="h-8 w-8 text-[11px] font-medium text-muted-foreground/70"
>
Wk
</th>
)}
{weekdays.map((w) => (
<th
key={w.long}
scope="col"
abbr={w.long}
className={cn(
"h-8 text-[11px] font-medium text-muted-foreground",
tall ? "w-10" : "w-9",
)}
>
{w.short}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((row) => {
// Days this row actually draws: side-by-side months
// leave the other month's days blank.
const shown =
count > 1 ? row.filter((d) => sameMonth(d, m)) : row;
const weekSelected =
!!week && sameDay(row[0], weekStart(week.from));
const weekPreview =
!weekSelected && sameDay(row[0], previewWeek);
// ISO weeks run Monday–Sunday; the row's Thursday
// decides which one a differently started row is.
const thursday =
row.find((d) => d.getDay() === 4) ?? row[0];
return (
<tr key={dayKey(row[0])}>
{showWeekNumber && (
<th
scope="row"
className={cn(
"p-0 text-center text-[11px] font-normal tabular-nums transition-colors",
weekSelected
? "font-medium text-foreground"
: "text-muted-foreground/70",
)}
>
{shown.length > 0 ? isoWeek(thursday) : null}
</th>
)}
{row.map((d) => {
const outside = !sameMonth(d, m);
// Side-by-side months would show these days twice.
if (outside && count > 1)
return (
<td key={dayKey(d)} className="p-0">
<span className={cn("block", cellSize)} />
</td>
);
const meta = getDayMeta?.(d) ?? null;
const disabled = isDisabled(d);
const nightsBlocked =
!disabled && blockedByNights(d);
const isToday = sameDay(d, today);
const isFrom = sameDay(d, range?.from);
const isTo = sameDay(d, bandEnd);
const inBand =
!!range?.from &&
!!bandEnd &&
d > range.from &&
d < bandEnd;
const chosen = isMultiple && isPicked(d);
const inWeek =
!!week &&
d >= startOfDay(week.from) &&
d <= startOfDay(week.to);
const selected = isRange
? isFrom || (isTo && !!range?.to)
: isWeek
? inWeek
: isMultiple
? chosen
: sameDay(d, single);
const endpoint = isFrom || isTo;
// Picked neighbours in the same row merge into one
// pill, so a run of days reads as a block.
const joinPrev =
chosen &&
column(d) > 0 &&
sameMonth(addDays(d, -1), m) &&
isPicked(addDays(d, -1));
const joinNext =
chosen &&
column(d) < 6 &&
sameMonth(addDays(d, 1), m) &&
isPicked(addDays(d, 1));
// The week pill lives in the row's first drawn
// cell and spans every drawn day after it.
const weekPill =
isWeek &&
(weekSelected || weekPreview) &&
sameDay(d, shown[0]);
return (
<td key={dayKey(d)} className="relative p-0">
{(inBand ||
(endpoint &&
bandEnd &&
!sameDay(range?.from, bandEnd))) && (
<span
aria-hidden
className={cn(
"absolute inset-y-0.5 bg-primary/10",
inBand && "inset-x-0",
isFrom && "left-1/2 right-0",
isTo && "left-0 right-1/2",
)}
/>
)}
{weekPill && (
<motion.span
aria-hidden
// Selected and previewed rows each glide
// between weeks, scoped per month.
layoutId={`${uid}-${mk}-${weekSelected ? "week" : "week-preview"}`}
transition={
reduce ? { duration: 0 } : SPRING_LAYOUT
}
style={{ width: `${shown.length * 100}%` }}
className={cn(
"pointer-events-none absolute inset-y-0 left-0 rounded-lg",
weekSelected ? "bg-primary" : "bg-muted",
)}
/>
)}
<button
type="button"
data-date={dayKey(d)}
tabIndex={
sameDay(d, focusInMonth) && !outside
? 0
: -1
}
aria-label={
meta?.label
? `${dayLabel.format(d)}, ${meta.label}`
: dayLabel.format(d)
}
aria-pressed={selected}
aria-disabled={
disabled || nightsBlocked || undefined
}
aria-current={isToday ? "date" : undefined}
title={
nightsBlocked
? daysBetween(range?.from ?? d, d) <
minNights
? `Minimum ${minNights} nights`
: `Maximum ${maxNights} nights`
: undefined
}
onClick={() => select(d)}
onFocus={() => {
setFocused(d);
if (isWeek) setHovered(d);
}}
onBlur={() => isWeek && setHovered(null)}
onPointerEnter={() =>
(isRange || isWeek) && setHovered(d)
}
className={cn(
"relative isolate flex items-center justify-center rounded-lg text-sm tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
cellSize,
tall && "flex-col gap-0.5",
outside
? "text-muted-foreground/50"
: "text-foreground",
!selected &&
!disabled &&
!nightsBlocked &&
!isWeek &&
"hover:bg-muted",
selected && "text-primary-foreground",
selected &&
outside &&
"text-primary-foreground/60",
isTo && !range?.to && "text-foreground",
nightsBlocked &&
"cursor-not-allowed text-muted-foreground/40",
disabled &&
"cursor-not-allowed text-muted-foreground/40 line-through",
)}
>
{selected &&
!isWeek &&
(isRange || isMultiple ? (
<motion.span
aria-hidden
initial={
reduce ? false : { scale: 0.7 }
}
animate={{ scale: 1 }}
transition={SPRING_PRESS}
className={cn(
"absolute inset-0 -z-10 rounded-lg bg-primary",
joinPrev && "rounded-l-none",
joinNext && "rounded-r-none",
)}
/>
) : (
<motion.span
aria-hidden
// Scoped per month so the pill glides
// between days but never across the
// sliding grids.
layoutId={`${uid}-${mk}-selected`}
transition={
reduce
? { duration: 0 }
: SPRING_LAYOUT
}
className="absolute inset-0 -z-10 rounded-lg bg-primary"
/>
))}
{isTo && !range?.to && (
<span
aria-hidden
className="absolute inset-0 -z-10 rounded-lg border border-primary/40"
/>
)}
{tall ? (
<span className="leading-none">
{d.getDate()}
</span>
) : (
d.getDate()
)}
{tall && (
<span
aria-hidden
className={cn(
"h-2.5 text-[10px] leading-none",
meta?.tone === "accent"
? // Tinted toward the foreground so
// the green keeps contrast on
// light and dark cards.
"font-medium text-[color:color-mix(in_oklab,var(--chart-3)_70%,var(--foreground))]"
: meta?.tone === "muted"
? "text-muted-foreground/60"
: "text-muted-foreground",
selected &&
"text-primary-foreground/75",
(disabled || nightsBlocked) &&
"text-muted-foreground/40",
)}
>
{meta?.label}
</span>
)}
{isToday && (
<span
aria-hidden
className={cn(
"absolute size-1 rounded-full",
tall ? "top-1.5 right-1.5" : "bottom-1",
selected
? "bg-primary-foreground"
: "bg-primary",
)}
/>
)}
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</motion.table>
</AnimatePresence>
</div>
</div>
);
})}
</div>
</div>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
/** The element the panel measures against — see `registerTrigger`. */
triggerRef: React.MutableRefObject<HTMLElement | null>;
registerTrigger: (node: HTMLElement | null) => void;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
// A trigger normally registers itself through MorphPopoverTrigger. It can't
// when something else already clones the element — a Tooltip wrapping the
// button, say — and an unregistered trigger leaves the panel with nothing to
// measure against, so it renders permanently invisible. The root boxes the
// trigger exactly (the content portals out of it), so it stands in until a
// real trigger registers, and stands in again if that one unmounts. Both are
// state, so a trigger arriving while the panel is open re-anchors it.
const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(
() => ({ current: trigger ?? root }),
[root, trigger],
);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: a dismissal hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded. When no trigger has
// registered, the root anchor stands in only if it can actually hold focus;
// there is nowhere better than where the keyboard already is, so leave it.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (!inPanel) return;
const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);
restore?.focus();
}, [root, setOpen, trigger]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
root &&
!root.contains(target) &&
!contentRef.current?.contains(target)
)
close();
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, root, close]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef: anchorRef,
registerTrigger: setTrigger,
contentRef,
}),
[open, setOpen, toggle, baseId, anchorRef],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={setRoot} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as React.MutableRefObject<T | null>).current = node;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergeRefs(childRef, ctx.registerTrigger),
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipHidden(side: Side, align: Align, radius: number) {
const top = side === "bottom" ? "0%" : "92%";
const bottom = side === "bottom" ? "92%" : "0%";
const right = align === "end" ? "0%" : "92%";
const left = align === "end" ? "92%" : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
const clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
className?: string;
}
export function MorphPopoverContent({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [portalReady, setPortalReady] = useState(false);
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
portalReady && ctx.open,
);
useEffect(() => setPortalReady(true), []);
const left = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
const top = layout
? side === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: layout.trigger.top - layout.content.height - sideOffset
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },
show: { opacity: 1, scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipHidden(side, align, radius),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipShown(radius),
transition: MORPH_CLIP_TRANSITION,
},
};
// Keep the server and first client render identical, then mount the portal.
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open ? (
<motion.div
data-morph-popover-portal=""
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "show"}
exit={reduce ? { opacity: 0 } : "hidden"}
transition={reduce ? { duration: 0.12 } : undefined}
style={{
left,
top,
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(side, align),
}}
className="fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]"
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role="dialog"
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
value?Date | null—defaultValue?Date | nullnullonValueChange?((date: Date) => void)—placeholder?stringPick a dateformatOptions?DateTimeFormatOptionsIntl options for the trigger label. Default: medium date.
{ dateStyle: "medium" }locale?stringDefault "en-US".
en-USminDate?Date—maxDate?Date—isDateDisabled?((date: Date) => boolean)—weekStartsOn?0 | 1 | 2 | 3 | 4 | 5 | 6—align?"end" | "start"startdisabled?boolean—className?string—aria-label?stringChoose dateCalendar
calendar.tsxInline single-date calendar; the selected pill glides between days. Weekends disabled in this demo.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/date-picker
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type DateRange = { from: Date; to?: Date };
/** A whole week row: `from` is its first day, `to` its seventh. */
export type WeekRange = { from: Date; to: Date };
/** Per-day extras from `getDayMeta`. */
export type CalendarDayMeta = {
/** Short text under the day number (a price, a count). Cells grow taller
* when any day has meta; the label is also read after the date. */
label?: string;
/** "accent" tints the label (deals, highlights); "muted" fades it. */
tone?: "muted" | "accent";
/** Blocks the day and strikes it through, like `isDateDisabled`. */
disabled?: boolean;
};
type CalendarBaseProps = {
/** Earliest selectable day (inclusive). */
minDate?: Date;
/** Latest selectable day (inclusive). */
maxDate?: Date;
/** Extra per-day rule, e.g. weekends or booked dates. */
isDateDisabled?: (date: Date) => boolean;
/** 0 = Sunday … 6 = Saturday. Default 0. */
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** BCP 47 locale for month, weekday and day labels. Default "en-US" — a
* fixed default keeps server and client output identical for hydration. */
locale?: string;
/** Controlled visible month (any day inside it). */
month?: Date;
/** Uncontrolled initial visible month. Default: the selection or today. */
defaultMonth?: Date;
onMonthChange?: (month: Date) => void;
/** Months shown side by side (stacked below `sm`). Default 1. With more
* than one, days outside each month are left blank so none repeats. */
numberOfMonths?: number;
/** Adds an ISO 8601 week-number column ("Wk") before each row. */
showWeekNumber?: boolean;
/** Per-day label, tone and blocking, e.g. nightly prices or booked days.
* Passing it switches the grid to taller cells that fit the label. */
getDayMeta?: (date: Date) => CalendarDayMeta | null | undefined;
className?: string;
"aria-label"?: string;
};
export type CalendarSingleProps = CalendarBaseProps & {
mode?: "single";
value?: Date | null;
defaultValue?: Date | null;
onValueChange?: (date: Date) => void;
};
export type CalendarRangeProps = CalendarBaseProps & {
mode: "range";
value?: DateRange | null;
defaultValue?: DateRange | null;
onValueChange?: (range: DateRange) => void;
/** Fewest nights (days between start and end) a finished range may span.
* While the end is being chosen, days too close are blocked. */
minNights?: number;
/** Most nights a finished range may span; farther days are blocked. */
maxNights?: number;
};
export type CalendarWeekProps = CalendarBaseProps & {
mode: "week";
value?: WeekRange | null;
defaultValue?: WeekRange | null;
/** Called with the picked day's whole row, honouring `weekStartsOn`. */
onValueChange?: (week: WeekRange) => void;
};
export type CalendarMultipleProps = CalendarBaseProps & {
mode: "multiple";
value?: Date[] | null;
defaultValue?: Date[] | null;
/** Called with the selection sorted by date. */
onValueChange?: (dates: Date[]) => void;
/** Most days that can be picked; further picks are ignored. */
max?: number;
};
export type CalendarProps =
| CalendarSingleProps
| CalendarRangeProps
| CalendarWeekProps
| CalendarMultipleProps;
// ── date helpers (local time, day precision) ──────────────────────────────
const startOfDay = (d: Date) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate());
const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
const addDays = (d: Date, n: number) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
/** Adds months, clamping the day so Jan 31 + 1 month lands on Feb 28/29. */
function addMonths(d: Date, n: number) {
const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
const lastDay = new Date(
target.getFullYear(),
target.getMonth() + 1,
0,
).getDate();
target.setDate(Math.min(d.getDate(), lastDay));
return target;
}
const sameDay = (a?: Date | null, b?: Date | null) =>
!!a &&
!!b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
const sameMonth = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
const dayKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const monthKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
/** Whole days from `a` to `b`; rounding absorbs DST hour shifts. */
const daysBetween = (a: Date, b: Date) =>
Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / 86_400_000);
/** ISO 8601 week number: weeks start Monday, week 1 holds Jan 4. */
function isoWeek(d: Date) {
const thursday = addDays(d, 3 - ((d.getDay() + 6) % 7));
const jan4 = new Date(thursday.getFullYear(), 0, 4);
const firstThursday = addDays(jan4, 3 - ((jan4.getDay() + 6) % 7));
return 1 + Math.round(daysBetween(firstThursday, thursday) / 7);
}
/** Always 6 weeks, so the panel never changes height between months. */
function buildWeeks(month: Date, weekStartsOn: number) {
const first = startOfMonth(month);
const offset = (first.getDay() - weekStartsOn + 7) % 7;
const start = addDays(first, -offset);
return Array.from({ length: 6 }, (_, w) =>
Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),
);
}
// Month swap is a short directional slide; the exit is quicker than the entry.
const MONTH_ENTER = { duration: 0.26, ease: EASE_OUT } as const;
const MONTH_EXIT = { duration: 0.16, ease: EASE_OUT } as const;
/**
* Month-grid calendar for single dates, ranges, whole weeks or several
* separate days, one or more months wide. The selected day is a pill that
* glides between days on a shared-layout spring; a picked week is one pill
* across its row; months slide in from the direction you travel. Optional ISO
* week numbers, per-day labels (`getDayMeta`) and night limits for ranges.
* Full keyboard grid navigation (arrows, Home/End, PageUp/PageDown,
* Shift+Page for years). Controlled or uncontrolled for both the selection
* and the visible month; reduced-motion safe.
*/
export function Calendar(props: CalendarProps) {
const {
minDate,
maxDate,
isDateDisabled,
weekStartsOn = 0,
locale = "en-US",
month: controlledMonth,
defaultMonth,
onMonthChange,
numberOfMonths = 1,
showWeekNumber = false,
getDayMeta,
className,
"aria-label": ariaLabel,
} = props;
const isRange = props.mode === "range";
const isWeek = props.mode === "week";
const isMultiple = props.mode === "multiple";
const count = Math.max(1, Math.floor(numberOfMonths));
const minNights = isRange ? ((props as CalendarRangeProps).minNights ?? 0) : 0;
const maxNights = isRange ? (props as CalendarRangeProps).maxNights : undefined;
// Taller cells only when there is something to put under the number.
const tall = !!getDayMeta;
const uid = useId();
const reduce = useReducedMotion();
// ── selection ──
const [internalValue, setInternalValue] = useState<
Date | DateRange | WeekRange | Date[] | null
>(props.defaultValue ?? null);
const valueControlled = props.value !== undefined;
const value = valueControlled ? (props.value ?? null) : internalValue;
const single =
isRange || isWeek || isMultiple ? null : (value as Date | null);
const range = isRange ? (value as DateRange | null) : null;
const week = isWeek ? (value as WeekRange | null) : null;
const picked = isMultiple ? ((value as Date[] | null) ?? []) : [];
const isPicked = (d: Date) => picked.some((p) => sameDay(p, d));
// ── visible months ──
const anchor = single ?? range?.from ?? week?.from ?? picked[0] ?? null;
const [internalMonth, setInternalMonth] = useState(() =>
startOfMonth(defaultMonth ?? anchor ?? new Date()),
);
const month = controlledMonth
? startOfMonth(controlledMonth)
: internalMonth;
const [direction, setDirection] = useState(1);
const lastMonth = addMonths(month, count - 1);
const inWindow = (d: Date) => {
const m = startOfMonth(d);
return m >= month && m <= lastMonth;
};
const setMonth = useCallback(
(next: Date) => {
const m = startOfMonth(next);
if (sameMonth(m, month)) return;
setDirection(m > month ? 1 : -1);
if (!controlledMonth) setInternalMonth(m);
onMonthChange?.(m);
},
[month, controlledMonth, onMonthChange],
);
// "Today" is read after mount: the server's clock and timezone can disagree
// with the viewer's, and a mismatched marker would break hydration.
const [today, setToday] = useState<Date | null>(null);
useEffect(() => setToday(startOfDay(new Date())), []);
// ── roving focus ──
const [focused, setFocused] = useState<Date>(() =>
startOfDay(anchor ?? defaultMonth ?? new Date()),
);
const gridRef = useRef<HTMLDivElement>(null);
const shouldFocus = useRef(false);
// Range hover preview: the band follows the pointer until the end is picked.
// In week mode it is the row under the pointer or keyboard focus.
const [hovered, setHovered] = useState<Date | null>(null);
// Keep the tab stop inside the visible months when they change by buttons.
const focusInMonth = inWindow(focused)
? focused
: anchor && inWindow(anchor)
? anchor
: month;
useEffect(() => {
if (!shouldFocus.current) return;
shouldFocus.current = false;
gridRef.current
?.querySelector<HTMLButtonElement>(
`[data-month="${monthKey(focusInMonth)}"] [data-date="${dayKey(focusInMonth)}"]`,
)
?.focus();
}, [focusInMonth]);
const isDisabled = useCallback(
(d: Date) =>
(minDate && d < startOfDay(minDate)) ||
(maxDate && d > startOfDay(maxDate)) ||
!!isDateDisabled?.(d) ||
!!getDayMeta?.(d)?.disabled,
[minDate, maxDate, isDateDisabled, getDayMeta],
);
// While a range end is being chosen, days that would make it too short or
// too long are blocked (earlier days stay open: they restart the range).
const choosingEnd = !!range && !range.to;
const nightsAllowed = (n: number) =>
n >= minNights && (maxNights === undefined || n <= maxNights);
const blockedByNights = (d: Date) =>
choosingEnd &&
!!range &&
d > range.from &&
!nightsAllowed(daysBetween(range.from, d));
const weekStart = (d: Date) =>
addDays(d, -((d.getDay() - weekStartsOn + 7) % 7));
const select = (d: Date) => {
if (isDisabled(d) || blockedByNights(d)) return;
if (!inWindow(d)) setMonth(d);
setFocused(d);
if (isWeek) {
const from = weekStart(d);
const next: WeekRange = { from, to: addDays(from, 6) };
if (!valueControlled) setInternalValue(next);
(props as CalendarWeekProps).onValueChange?.(next);
return;
}
if (isMultiple) {
const max = (props as CalendarMultipleProps).max;
const next = isPicked(d)
? picked.filter((p) => !sameDay(p, d))
: max !== undefined && picked.length >= max
? null
: [...picked, d].sort((a, b) => a.getTime() - b.getTime());
if (!next) return;
if (!valueControlled) setInternalValue(next);
(props as CalendarMultipleProps).onValueChange?.(next);
return;
}
if (!isRange) {
if (!valueControlled) setInternalValue(d);
(props as CalendarSingleProps).onValueChange?.(d);
return;
}
// First click starts a range; a later day closes it; an earlier day, the
// start again when it would be too short, or a click on a finished range
// starts over.
const next: DateRange =
range &&
!range.to &&
d >= range.from &&
nightsAllowed(daysBetween(range.from, d))
? { from: range.from, to: d }
: { from: d };
if (!valueControlled) setInternalValue(next);
(props as CalendarRangeProps).onValueChange?.(next);
};
const onGridKeyDown = (e: KeyboardEvent) => {
const base = focusInMonth;
let next: Date | null = null;
switch (e.key) {
case "ArrowLeft":
next = addDays(base, -1);
break;
case "ArrowRight":
next = addDays(base, 1);
break;
case "ArrowUp":
next = addDays(base, -7);
break;
case "ArrowDown":
next = addDays(base, 7);
break;
case "Home":
next = addDays(base, -((base.getDay() - weekStartsOn + 7) % 7));
break;
case "End":
next = addDays(base, 6 - ((base.getDay() - weekStartsOn + 7) % 7));
break;
case "PageUp":
next = addMonths(base, e.shiftKey ? -12 : -1);
break;
case "PageDown":
next = addMonths(base, e.shiftKey ? 12 : 1);
break;
default:
return;
}
e.preventDefault();
shouldFocus.current = true;
setFocused(next);
// Scroll the window only when focus leaves it, by as little as needed.
if (!inWindow(next))
setMonth(next < month ? next : addMonths(startOfMonth(next), 1 - count));
};
// ── labels ──
const monthFormat = useMemo(
() => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
[locale],
);
const weekdays = useMemo(() => {
const short = new Intl.DateTimeFormat(locale, { weekday: "short" });
const long = new Intl.DateTimeFormat(locale, { weekday: "long" });
// 2023-01-01 was a Sunday.
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7));
return { short: short.format(d).slice(0, 2), long: long.format(d) };
});
}, [locale, weekStartsOn]);
const dayLabel = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
}),
[locale],
);
const prevDisabled =
!!minDate && startOfMonth(minDate) >= month;
const nextDisabled =
!!maxDate && startOfMonth(maxDate) <= lastMonth;
// Range band end: the picked end, or the hovered day while choosing one.
// A day that can't close the range doesn't stretch the band to it.
const bandEnd =
range &&
!range.to &&
hovered &&
hovered > range.from &&
!isDisabled(hovered) &&
!blockedByNights(hovered)
? hovered
: range?.to;
// Week mode: the row being previewed (pointer or keyboard focus).
const previewWeek = isWeek && hovered ? weekStart(hovered) : null;
const navButton =
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40";
const months = Array.from({ length: count }, (_, i) => addMonths(month, i));
// Weekday index of the column, so connecting bands stop at the row ends.
const column = (d: Date) => (d.getDay() - weekStartsOn + 7) % 7;
const cellSize = tall ? "h-12 w-10" : "size-9";
return (
<div
className={cn(
"w-fit rounded-2xl border border-border bg-card p-3 text-foreground",
className,
)}
>
<div
ref={gridRef}
onPointerLeave={() => setHovered(null)}
className="flex flex-col gap-x-6 gap-y-4 sm:flex-row"
>
{months.map((m, i) => {
const mk = monthKey(m);
const weeks = buildWeeks(m, weekStartsOn);
const labelId = `${uid}-label-${i}`;
return (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: columns are positional; each keeps its own month transition.
key={i}
className="min-w-0"
>
<div className="mb-2 flex items-center justify-between gap-2 px-1">
{i === 0 ? (
<button
type="button"
aria-label="Previous month"
disabled={prevDisabled}
onClick={() => setMonth(addMonths(month, -1))}
className={navButton}
>
<ChevronLeft className="h-4 w-4" />
</button>
) : (
<span aria-hidden className="size-8" />
)}
<h2
id={labelId}
aria-live={i === 0 ? "polite" : undefined}
className="text-sm font-medium tabular-nums"
>
{monthFormat.format(m)}
</h2>
{i === count - 1 ? (
<button
type="button"
aria-label="Next month"
disabled={nextDisabled}
onClick={() => setMonth(addMonths(month, 1))}
className={navButton}
>
<ChevronRight className="h-4 w-4" />
</button>
) : (
<span aria-hidden className="size-8" />
)}
</div>
<div className="relative overflow-hidden">
<AnimatePresence
initial={false}
mode="popLayout"
custom={direction}
>
<motion.table
key={mk}
data-month={mk}
custom={direction}
aria-labelledby={ariaLabel ? undefined : labelId}
aria-label={ariaLabel ? `${ariaLabel}, ${monthFormat.format(m)}` : undefined}
onKeyDown={onGridKeyDown}
variants={{
enter: (dir: number) => ({
opacity: 0,
x: reduce ? 0 : dir * 28,
filter: reduce ? "blur(0px)" : "blur(4px)",
}),
center: {
opacity: 1,
x: 0,
filter: "blur(0px)",
transition: MONTH_ENTER,
},
exit: (dir: number) => ({
opacity: 0,
x: reduce ? 0 : dir * -28,
filter: reduce ? "blur(0px)" : "blur(4px)",
transition: MONTH_EXIT,
}),
}}
initial="enter"
animate="center"
exit="exit"
className="border-collapse"
>
<thead>
<tr>
{showWeekNumber && (
<th
scope="col"
abbr="Week"
className="h-8 w-8 text-[11px] font-medium text-muted-foreground/70"
>
Wk
</th>
)}
{weekdays.map((w) => (
<th
key={w.long}
scope="col"
abbr={w.long}
className={cn(
"h-8 text-[11px] font-medium text-muted-foreground",
tall ? "w-10" : "w-9",
)}
>
{w.short}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((row) => {
// Days this row actually draws: side-by-side months
// leave the other month's days blank.
const shown =
count > 1 ? row.filter((d) => sameMonth(d, m)) : row;
const weekSelected =
!!week && sameDay(row[0], weekStart(week.from));
const weekPreview =
!weekSelected && sameDay(row[0], previewWeek);
// ISO weeks run Monday–Sunday; the row's Thursday
// decides which one a differently started row is.
const thursday =
row.find((d) => d.getDay() === 4) ?? row[0];
return (
<tr key={dayKey(row[0])}>
{showWeekNumber && (
<th
scope="row"
className={cn(
"p-0 text-center text-[11px] font-normal tabular-nums transition-colors",
weekSelected
? "font-medium text-foreground"
: "text-muted-foreground/70",
)}
>
{shown.length > 0 ? isoWeek(thursday) : null}
</th>
)}
{row.map((d) => {
const outside = !sameMonth(d, m);
// Side-by-side months would show these days twice.
if (outside && count > 1)
return (
<td key={dayKey(d)} className="p-0">
<span className={cn("block", cellSize)} />
</td>
);
const meta = getDayMeta?.(d) ?? null;
const disabled = isDisabled(d);
const nightsBlocked =
!disabled && blockedByNights(d);
const isToday = sameDay(d, today);
const isFrom = sameDay(d, range?.from);
const isTo = sameDay(d, bandEnd);
const inBand =
!!range?.from &&
!!bandEnd &&
d > range.from &&
d < bandEnd;
const chosen = isMultiple && isPicked(d);
const inWeek =
!!week &&
d >= startOfDay(week.from) &&
d <= startOfDay(week.to);
const selected = isRange
? isFrom || (isTo && !!range?.to)
: isWeek
? inWeek
: isMultiple
? chosen
: sameDay(d, single);
const endpoint = isFrom || isTo;
// Picked neighbours in the same row merge into one
// pill, so a run of days reads as a block.
const joinPrev =
chosen &&
column(d) > 0 &&
sameMonth(addDays(d, -1), m) &&
isPicked(addDays(d, -1));
const joinNext =
chosen &&
column(d) < 6 &&
sameMonth(addDays(d, 1), m) &&
isPicked(addDays(d, 1));
// The week pill lives in the row's first drawn
// cell and spans every drawn day after it.
const weekPill =
isWeek &&
(weekSelected || weekPreview) &&
sameDay(d, shown[0]);
return (
<td key={dayKey(d)} className="relative p-0">
{(inBand ||
(endpoint &&
bandEnd &&
!sameDay(range?.from, bandEnd))) && (
<span
aria-hidden
className={cn(
"absolute inset-y-0.5 bg-primary/10",
inBand && "inset-x-0",
isFrom && "left-1/2 right-0",
isTo && "left-0 right-1/2",
)}
/>
)}
{weekPill && (
<motion.span
aria-hidden
// Selected and previewed rows each glide
// between weeks, scoped per month.
layoutId={`${uid}-${mk}-${weekSelected ? "week" : "week-preview"}`}
transition={
reduce ? { duration: 0 } : SPRING_LAYOUT
}
style={{ width: `${shown.length * 100}%` }}
className={cn(
"pointer-events-none absolute inset-y-0 left-0 rounded-lg",
weekSelected ? "bg-primary" : "bg-muted",
)}
/>
)}
<button
type="button"
data-date={dayKey(d)}
tabIndex={
sameDay(d, focusInMonth) && !outside
? 0
: -1
}
aria-label={
meta?.label
? `${dayLabel.format(d)}, ${meta.label}`
: dayLabel.format(d)
}
aria-pressed={selected}
aria-disabled={
disabled || nightsBlocked || undefined
}
aria-current={isToday ? "date" : undefined}
title={
nightsBlocked
? daysBetween(range?.from ?? d, d) <
minNights
? `Minimum ${minNights} nights`
: `Maximum ${maxNights} nights`
: undefined
}
onClick={() => select(d)}
onFocus={() => {
setFocused(d);
if (isWeek) setHovered(d);
}}
onBlur={() => isWeek && setHovered(null)}
onPointerEnter={() =>
(isRange || isWeek) && setHovered(d)
}
className={cn(
"relative isolate flex items-center justify-center rounded-lg text-sm tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
cellSize,
tall && "flex-col gap-0.5",
outside
? "text-muted-foreground/50"
: "text-foreground",
!selected &&
!disabled &&
!nightsBlocked &&
!isWeek &&
"hover:bg-muted",
selected && "text-primary-foreground",
selected &&
outside &&
"text-primary-foreground/60",
isTo && !range?.to && "text-foreground",
nightsBlocked &&
"cursor-not-allowed text-muted-foreground/40",
disabled &&
"cursor-not-allowed text-muted-foreground/40 line-through",
)}
>
{selected &&
!isWeek &&
(isRange || isMultiple ? (
<motion.span
aria-hidden
initial={
reduce ? false : { scale: 0.7 }
}
animate={{ scale: 1 }}
transition={SPRING_PRESS}
className={cn(
"absolute inset-0 -z-10 rounded-lg bg-primary",
joinPrev && "rounded-l-none",
joinNext && "rounded-r-none",
)}
/>
) : (
<motion.span
aria-hidden
// Scoped per month so the pill glides
// between days but never across the
// sliding grids.
layoutId={`${uid}-${mk}-selected`}
transition={
reduce
? { duration: 0 }
: SPRING_LAYOUT
}
className="absolute inset-0 -z-10 rounded-lg bg-primary"
/>
))}
{isTo && !range?.to && (
<span
aria-hidden
className="absolute inset-0 -z-10 rounded-lg border border-primary/40"
/>
)}
{tall ? (
<span className="leading-none">
{d.getDate()}
</span>
) : (
d.getDate()
)}
{tall && (
<span
aria-hidden
className={cn(
"h-2.5 text-[10px] leading-none",
meta?.tone === "accent"
? // Tinted toward the foreground so
// the green keeps contrast on
// light and dark cards.
"font-medium text-[color:color-mix(in_oklab,var(--chart-3)_70%,var(--foreground))]"
: meta?.tone === "muted"
? "text-muted-foreground/60"
: "text-muted-foreground",
selected &&
"text-primary-foreground/75",
(disabled || nightsBlocked) &&
"text-muted-foreground/40",
)}
>
{meta?.label}
</span>
)}
{isToday && (
<span
aria-hidden
className={cn(
"absolute size-1 rounded-full",
tall ? "top-1.5 right-1.5" : "bottom-1",
selected
? "bg-primary-foreground"
: "bg-primary",
)}
/>
)}
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</motion.table>
</AnimatePresence>
</div>
</div>
);
})}
</div>
</div>
);
}
API Reference
minDate?DateEarliest selectable day (inclusive).
—maxDate?DateLatest selectable day (inclusive).
—isDateDisabled?((date: Date) => boolean)Extra per-day rule, e.g. weekends or booked dates.
—weekStartsOn?0 | 1 | 2 | 3 | 4 | 5 | 60 = Sunday … 6 = Saturday. Default 0.
—locale?stringBCP 47 locale for month, weekday and day labels. Default "en-US" — a fixed default keeps server and client output identical for hydration.
—month?DateControlled visible month (any day inside it).
—defaultMonth?DateUncontrolled initial visible month. Default: the selection or today.
—onMonthChange?((month: Date) => void)—numberOfMonths?numberMonths shown side by side (stacked below `sm`). Default 1. With more than one, days outside each month are left blank so none repeats.
—showWeekNumber?booleanAdds an ISO 8601 week-number column ("Wk") before each row.
—getDayMeta?((date: Date) => CalendarDayMeta | null)Per-day label, tone and blocking, e.g. nightly prices or booked days. Passing it switches the grid to taller cells that fit the label.
—className?string—aria-label?string—mode?"multiple" | "range" | "week" | "single"—value?Date | DateRange | WeekRange | Date[] | null—defaultValue?Date | DateRange | WeekRange | Date[] | null—onValueChange?((date: Date) => void) | ((range: DateRange) => void) | ((week: WeekRange) => void) | ((dates: Date[]) => void)Called with the picked day's whole row, honouring `weekStartsOn`. Called with the selection sorted by date.
—minNights?numberFewest nights (days between start and end) a finished range may span. While the end is being chosen, days too close are blocked.
—maxNights?numberMost nights a finished range may span; farther days are blocked.
—max?numberMost days that can be picked; further picks are ignored.
—Range
calendar.tsxTwo months side by side; pick a start and end day and the band follows the pointer across both until the range closes.
API Reference
minDate?DateEarliest selectable day (inclusive).
—maxDate?DateLatest selectable day (inclusive).
—isDateDisabled?((date: Date) => boolean)Extra per-day rule, e.g. weekends or booked dates.
—weekStartsOn?0 | 1 | 2 | 3 | 4 | 5 | 60 = Sunday … 6 = Saturday. Default 0.
—locale?stringBCP 47 locale for month, weekday and day labels. Default "en-US" — a fixed default keeps server and client output identical for hydration.
—month?DateControlled visible month (any day inside it).
—defaultMonth?DateUncontrolled initial visible month. Default: the selection or today.
—onMonthChange?((month: Date) => void)—numberOfMonths?numberMonths shown side by side (stacked below `sm`). Default 1. With more than one, days outside each month are left blank so none repeats.
—showWeekNumber?booleanAdds an ISO 8601 week-number column ("Wk") before each row.
—getDayMeta?((date: Date) => CalendarDayMeta | null)Per-day label, tone and blocking, e.g. nightly prices or booked days. Passing it switches the grid to taller cells that fit the label.
—className?string—aria-label?string—mode?"multiple" | "range" | "week" | "single"—value?Date | DateRange | WeekRange | Date[] | null—defaultValue?Date | DateRange | WeekRange | Date[] | null—onValueChange?((date: Date) => void) | ((range: DateRange) => void) | ((week: WeekRange) => void) | ((dates: Date[]) => void)Called with the picked day's whole row, honouring `weekStartsOn`. Called with the selection sorted by date.
—minNights?numberFewest nights (days between start and end) a finished range may span. While the end is being chosen, days too close are blocked.
—maxNights?numberMost nights a finished range may span; farther days are blocked.
—max?numberMost days that can be picked; further picks are ignored.
—Date Range Picker
date-range-picker.tsxRange field whose two-month calendar morphs open beside one-click presets and commits once the end day is picked.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/date-picker
import { CalendarRange } from "lucide-react";
import { useMemo, useState } from "react";
import { Calendar, type DateRange } from "@/components/motion/calendar";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { cn } from "@/lib/utils";
const SHORT_DATE: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
year: "numeric",
};
export type DateRangePreset = {
label: string;
/** Called on click, so "today" is read when the preset is used. */
range: () => { from: Date; to: Date };
};
export interface DateRangePickerProps {
value?: DateRange | null;
defaultValue?: DateRange | null;
/** Fires once both ends are picked (or a preset is chosen). */
onValueChange?: (range: { from: Date; to: Date }) => void;
placeholder?: string;
/** Quick picks listed beside the calendar. */
presets?: DateRangePreset[];
/** Months side by side. Default 2 (stacked below `sm`). */
numberOfMonths?: number;
/** Intl options for the trigger label. Default: "Oct 14 – 20, 2026". */
formatOptions?: Intl.DateTimeFormatOptions;
/** Default "en-US". */
locale?: string;
minDate?: Date;
maxDate?: Date;
isDateDisabled?: (date: Date) => boolean;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
align?: "start" | "end";
disabled?: boolean;
className?: string;
"aria-label"?: string;
}
/**
* Range field whose two-month calendar morphs open from the trigger. The band
* follows the pointer until the end day is picked, then the popover closes
* and commits; optional presets commit in one click. Picking only a start
* and closing keeps the previous range. Controlled or uncontrolled.
*/
export function DateRangePicker({
value: controlledValue,
defaultValue = null,
onValueChange,
placeholder = "Pick a date range",
presets,
numberOfMonths = 2,
formatOptions = SHORT_DATE,
locale = "en-US",
minDate,
maxDate,
isDateDisabled,
weekStartsOn,
align = "start",
disabled,
className,
"aria-label": ariaLabel = "Choose date range",
}: DateRangePickerProps) {
const [open, setOpen] = useState(false);
const [internalValue, setInternalValue] = useState<DateRange | null>(
defaultValue,
);
const value =
controlledValue !== undefined ? controlledValue : internalValue;
// In-progress pick; only a finished range reaches `value`.
const [draft, setDraft] = useState<DateRange | null>(value);
const format = useMemo(
() => new Intl.DateTimeFormat(locale, formatOptions),
[locale, formatOptions],
);
const label =
value?.to != null
? // Not formatRange: its separators differ between ICU builds (Node on
// the server vs the browser), which breaks hydration.
`${format.format(value.from)} – ${format.format(value.to)}`
: value
? format.format(value.from)
: null;
const commit = (range: { from: Date; to: Date }) => {
if (controlledValue === undefined) setInternalValue(range);
onValueChange?.(range);
setOpen(false);
};
const onOpenChange = (next: boolean) => {
// Each opening starts from the committed range.
if (next) setDraft(value);
setOpen(next);
};
return (
<MorphPopover open={open} onOpenChange={onOpenChange}>
<MorphPopoverTrigger>
<button
type="button"
disabled={disabled}
aria-label={label ? `${ariaLabel}, ${label}` : ariaLabel}
className={cn(
"inline-flex h-10 min-w-60 items-center gap-2 rounded-xl border border-border bg-background px-3 text-left text-sm outline-none transition-colors hover:border-border-strong focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
label ? "text-foreground" : "text-muted-foreground",
className,
)}
>
<CalendarRange className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate tabular-nums">
{label ?? placeholder}
</span>
</button>
</MorphPopoverTrigger>
<MorphPopoverContent align={align} className="p-0">
<div className="flex flex-col sm:flex-row">
{presets && presets.length > 0 && (
<ul className="flex gap-1 overflow-x-auto border-b border-border p-2 [scrollbar-width:none] sm:flex-col sm:overflow-visible sm:border-r sm:border-b-0">
{presets.map((p) => (
<li key={p.label} className="shrink-0">
<button
type="button"
onClick={() => commit(p.range())}
className="w-full whitespace-nowrap rounded-lg px-3 py-1.5 text-left text-sm text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{p.label}
</button>
</li>
))}
</ul>
)}
<Calendar
mode="range"
numberOfMonths={numberOfMonths}
value={draft}
onValueChange={(r) => {
setDraft(r);
if (r.to) commit({ from: r.from, to: r.to });
}}
locale={locale}
minDate={minDate}
maxDate={maxDate}
isDateDisabled={isDateDisabled}
weekStartsOn={weekStartsOn}
className="border-0 bg-transparent"
/>
</div>
</MorphPopoverContent>
</MorphPopover>
);
}
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type DateRange = { from: Date; to?: Date };
/** A whole week row: `from` is its first day, `to` its seventh. */
export type WeekRange = { from: Date; to: Date };
/** Per-day extras from `getDayMeta`. */
export type CalendarDayMeta = {
/** Short text under the day number (a price, a count). Cells grow taller
* when any day has meta; the label is also read after the date. */
label?: string;
/** "accent" tints the label (deals, highlights); "muted" fades it. */
tone?: "muted" | "accent";
/** Blocks the day and strikes it through, like `isDateDisabled`. */
disabled?: boolean;
};
type CalendarBaseProps = {
/** Earliest selectable day (inclusive). */
minDate?: Date;
/** Latest selectable day (inclusive). */
maxDate?: Date;
/** Extra per-day rule, e.g. weekends or booked dates. */
isDateDisabled?: (date: Date) => boolean;
/** 0 = Sunday … 6 = Saturday. Default 0. */
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** BCP 47 locale for month, weekday and day labels. Default "en-US" — a
* fixed default keeps server and client output identical for hydration. */
locale?: string;
/** Controlled visible month (any day inside it). */
month?: Date;
/** Uncontrolled initial visible month. Default: the selection or today. */
defaultMonth?: Date;
onMonthChange?: (month: Date) => void;
/** Months shown side by side (stacked below `sm`). Default 1. With more
* than one, days outside each month are left blank so none repeats. */
numberOfMonths?: number;
/** Adds an ISO 8601 week-number column ("Wk") before each row. */
showWeekNumber?: boolean;
/** Per-day label, tone and blocking, e.g. nightly prices or booked days.
* Passing it switches the grid to taller cells that fit the label. */
getDayMeta?: (date: Date) => CalendarDayMeta | null | undefined;
className?: string;
"aria-label"?: string;
};
export type CalendarSingleProps = CalendarBaseProps & {
mode?: "single";
value?: Date | null;
defaultValue?: Date | null;
onValueChange?: (date: Date) => void;
};
export type CalendarRangeProps = CalendarBaseProps & {
mode: "range";
value?: DateRange | null;
defaultValue?: DateRange | null;
onValueChange?: (range: DateRange) => void;
/** Fewest nights (days between start and end) a finished range may span.
* While the end is being chosen, days too close are blocked. */
minNights?: number;
/** Most nights a finished range may span; farther days are blocked. */
maxNights?: number;
};
export type CalendarWeekProps = CalendarBaseProps & {
mode: "week";
value?: WeekRange | null;
defaultValue?: WeekRange | null;
/** Called with the picked day's whole row, honouring `weekStartsOn`. */
onValueChange?: (week: WeekRange) => void;
};
export type CalendarMultipleProps = CalendarBaseProps & {
mode: "multiple";
value?: Date[] | null;
defaultValue?: Date[] | null;
/** Called with the selection sorted by date. */
onValueChange?: (dates: Date[]) => void;
/** Most days that can be picked; further picks are ignored. */
max?: number;
};
export type CalendarProps =
| CalendarSingleProps
| CalendarRangeProps
| CalendarWeekProps
| CalendarMultipleProps;
// ── date helpers (local time, day precision) ──────────────────────────────
const startOfDay = (d: Date) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate());
const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
const addDays = (d: Date, n: number) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
/** Adds months, clamping the day so Jan 31 + 1 month lands on Feb 28/29. */
function addMonths(d: Date, n: number) {
const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
const lastDay = new Date(
target.getFullYear(),
target.getMonth() + 1,
0,
).getDate();
target.setDate(Math.min(d.getDate(), lastDay));
return target;
}
const sameDay = (a?: Date | null, b?: Date | null) =>
!!a &&
!!b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
const sameMonth = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
const dayKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const monthKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
/** Whole days from `a` to `b`; rounding absorbs DST hour shifts. */
const daysBetween = (a: Date, b: Date) =>
Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / 86_400_000);
/** ISO 8601 week number: weeks start Monday, week 1 holds Jan 4. */
function isoWeek(d: Date) {
const thursday = addDays(d, 3 - ((d.getDay() + 6) % 7));
const jan4 = new Date(thursday.getFullYear(), 0, 4);
const firstThursday = addDays(jan4, 3 - ((jan4.getDay() + 6) % 7));
return 1 + Math.round(daysBetween(firstThursday, thursday) / 7);
}
/** Always 6 weeks, so the panel never changes height between months. */
function buildWeeks(month: Date, weekStartsOn: number) {
const first = startOfMonth(month);
const offset = (first.getDay() - weekStartsOn + 7) % 7;
const start = addDays(first, -offset);
return Array.from({ length: 6 }, (_, w) =>
Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),
);
}
// Month swap is a short directional slide; the exit is quicker than the entry.
const MONTH_ENTER = { duration: 0.26, ease: EASE_OUT } as const;
const MONTH_EXIT = { duration: 0.16, ease: EASE_OUT } as const;
/**
* Month-grid calendar for single dates, ranges, whole weeks or several
* separate days, one or more months wide. The selected day is a pill that
* glides between days on a shared-layout spring; a picked week is one pill
* across its row; months slide in from the direction you travel. Optional ISO
* week numbers, per-day labels (`getDayMeta`) and night limits for ranges.
* Full keyboard grid navigation (arrows, Home/End, PageUp/PageDown,
* Shift+Page for years). Controlled or uncontrolled for both the selection
* and the visible month; reduced-motion safe.
*/
export function Calendar(props: CalendarProps) {
const {
minDate,
maxDate,
isDateDisabled,
weekStartsOn = 0,
locale = "en-US",
month: controlledMonth,
defaultMonth,
onMonthChange,
numberOfMonths = 1,
showWeekNumber = false,
getDayMeta,
className,
"aria-label": ariaLabel,
} = props;
const isRange = props.mode === "range";
const isWeek = props.mode === "week";
const isMultiple = props.mode === "multiple";
const count = Math.max(1, Math.floor(numberOfMonths));
const minNights = isRange ? ((props as CalendarRangeProps).minNights ?? 0) : 0;
const maxNights = isRange ? (props as CalendarRangeProps).maxNights : undefined;
// Taller cells only when there is something to put under the number.
const tall = !!getDayMeta;
const uid = useId();
const reduce = useReducedMotion();
// ── selection ──
const [internalValue, setInternalValue] = useState<
Date | DateRange | WeekRange | Date[] | null
>(props.defaultValue ?? null);
const valueControlled = props.value !== undefined;
const value = valueControlled ? (props.value ?? null) : internalValue;
const single =
isRange || isWeek || isMultiple ? null : (value as Date | null);
const range = isRange ? (value as DateRange | null) : null;
const week = isWeek ? (value as WeekRange | null) : null;
const picked = isMultiple ? ((value as Date[] | null) ?? []) : [];
const isPicked = (d: Date) => picked.some((p) => sameDay(p, d));
// ── visible months ──
const anchor = single ?? range?.from ?? week?.from ?? picked[0] ?? null;
const [internalMonth, setInternalMonth] = useState(() =>
startOfMonth(defaultMonth ?? anchor ?? new Date()),
);
const month = controlledMonth
? startOfMonth(controlledMonth)
: internalMonth;
const [direction, setDirection] = useState(1);
const lastMonth = addMonths(month, count - 1);
const inWindow = (d: Date) => {
const m = startOfMonth(d);
return m >= month && m <= lastMonth;
};
const setMonth = useCallback(
(next: Date) => {
const m = startOfMonth(next);
if (sameMonth(m, month)) return;
setDirection(m > month ? 1 : -1);
if (!controlledMonth) setInternalMonth(m);
onMonthChange?.(m);
},
[month, controlledMonth, onMonthChange],
);
// "Today" is read after mount: the server's clock and timezone can disagree
// with the viewer's, and a mismatched marker would break hydration.
const [today, setToday] = useState<Date | null>(null);
useEffect(() => setToday(startOfDay(new Date())), []);
// ── roving focus ──
const [focused, setFocused] = useState<Date>(() =>
startOfDay(anchor ?? defaultMonth ?? new Date()),
);
const gridRef = useRef<HTMLDivElement>(null);
const shouldFocus = useRef(false);
// Range hover preview: the band follows the pointer until the end is picked.
// In week mode it is the row under the pointer or keyboard focus.
const [hovered, setHovered] = useState<Date | null>(null);
// Keep the tab stop inside the visible months when they change by buttons.
const focusInMonth = inWindow(focused)
? focused
: anchor && inWindow(anchor)
? anchor
: month;
useEffect(() => {
if (!shouldFocus.current) return;
shouldFocus.current = false;
gridRef.current
?.querySelector<HTMLButtonElement>(
`[data-month="${monthKey(focusInMonth)}"] [data-date="${dayKey(focusInMonth)}"]`,
)
?.focus();
}, [focusInMonth]);
const isDisabled = useCallback(
(d: Date) =>
(minDate && d < startOfDay(minDate)) ||
(maxDate && d > startOfDay(maxDate)) ||
!!isDateDisabled?.(d) ||
!!getDayMeta?.(d)?.disabled,
[minDate, maxDate, isDateDisabled, getDayMeta],
);
// While a range end is being chosen, days that would make it too short or
// too long are blocked (earlier days stay open: they restart the range).
const choosingEnd = !!range && !range.to;
const nightsAllowed = (n: number) =>
n >= minNights && (maxNights === undefined || n <= maxNights);
const blockedByNights = (d: Date) =>
choosingEnd &&
!!range &&
d > range.from &&
!nightsAllowed(daysBetween(range.from, d));
const weekStart = (d: Date) =>
addDays(d, -((d.getDay() - weekStartsOn + 7) % 7));
const select = (d: Date) => {
if (isDisabled(d) || blockedByNights(d)) return;
if (!inWindow(d)) setMonth(d);
setFocused(d);
if (isWeek) {
const from = weekStart(d);
const next: WeekRange = { from, to: addDays(from, 6) };
if (!valueControlled) setInternalValue(next);
(props as CalendarWeekProps).onValueChange?.(next);
return;
}
if (isMultiple) {
const max = (props as CalendarMultipleProps).max;
const next = isPicked(d)
? picked.filter((p) => !sameDay(p, d))
: max !== undefined && picked.length >= max
? null
: [...picked, d].sort((a, b) => a.getTime() - b.getTime());
if (!next) return;
if (!valueControlled) setInternalValue(next);
(props as CalendarMultipleProps).onValueChange?.(next);
return;
}
if (!isRange) {
if (!valueControlled) setInternalValue(d);
(props as CalendarSingleProps).onValueChange?.(d);
return;
}
// First click starts a range; a later day closes it; an earlier day, the
// start again when it would be too short, or a click on a finished range
// starts over.
const next: DateRange =
range &&
!range.to &&
d >= range.from &&
nightsAllowed(daysBetween(range.from, d))
? { from: range.from, to: d }
: { from: d };
if (!valueControlled) setInternalValue(next);
(props as CalendarRangeProps).onValueChange?.(next);
};
const onGridKeyDown = (e: KeyboardEvent) => {
const base = focusInMonth;
let next: Date | null = null;
switch (e.key) {
case "ArrowLeft":
next = addDays(base, -1);
break;
case "ArrowRight":
next = addDays(base, 1);
break;
case "ArrowUp":
next = addDays(base, -7);
break;
case "ArrowDown":
next = addDays(base, 7);
break;
case "Home":
next = addDays(base, -((base.getDay() - weekStartsOn + 7) % 7));
break;
case "End":
next = addDays(base, 6 - ((base.getDay() - weekStartsOn + 7) % 7));
break;
case "PageUp":
next = addMonths(base, e.shiftKey ? -12 : -1);
break;
case "PageDown":
next = addMonths(base, e.shiftKey ? 12 : 1);
break;
default:
return;
}
e.preventDefault();
shouldFocus.current = true;
setFocused(next);
// Scroll the window only when focus leaves it, by as little as needed.
if (!inWindow(next))
setMonth(next < month ? next : addMonths(startOfMonth(next), 1 - count));
};
// ── labels ──
const monthFormat = useMemo(
() => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
[locale],
);
const weekdays = useMemo(() => {
const short = new Intl.DateTimeFormat(locale, { weekday: "short" });
const long = new Intl.DateTimeFormat(locale, { weekday: "long" });
// 2023-01-01 was a Sunday.
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7));
return { short: short.format(d).slice(0, 2), long: long.format(d) };
});
}, [locale, weekStartsOn]);
const dayLabel = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
}),
[locale],
);
const prevDisabled =
!!minDate && startOfMonth(minDate) >= month;
const nextDisabled =
!!maxDate && startOfMonth(maxDate) <= lastMonth;
// Range band end: the picked end, or the hovered day while choosing one.
// A day that can't close the range doesn't stretch the band to it.
const bandEnd =
range &&
!range.to &&
hovered &&
hovered > range.from &&
!isDisabled(hovered) &&
!blockedByNights(hovered)
? hovered
: range?.to;
// Week mode: the row being previewed (pointer or keyboard focus).
const previewWeek = isWeek && hovered ? weekStart(hovered) : null;
const navButton =
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40";
const months = Array.from({ length: count }, (_, i) => addMonths(month, i));
// Weekday index of the column, so connecting bands stop at the row ends.
const column = (d: Date) => (d.getDay() - weekStartsOn + 7) % 7;
const cellSize = tall ? "h-12 w-10" : "size-9";
return (
<div
className={cn(
"w-fit rounded-2xl border border-border bg-card p-3 text-foreground",
className,
)}
>
<div
ref={gridRef}
onPointerLeave={() => setHovered(null)}
className="flex flex-col gap-x-6 gap-y-4 sm:flex-row"
>
{months.map((m, i) => {
const mk = monthKey(m);
const weeks = buildWeeks(m, weekStartsOn);
const labelId = `${uid}-label-${i}`;
return (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: columns are positional; each keeps its own month transition.
key={i}
className="min-w-0"
>
<div className="mb-2 flex items-center justify-between gap-2 px-1">
{i === 0 ? (
<button
type="button"
aria-label="Previous month"
disabled={prevDisabled}
onClick={() => setMonth(addMonths(month, -1))}
className={navButton}
>
<ChevronLeft className="h-4 w-4" />
</button>
) : (
<span aria-hidden className="size-8" />
)}
<h2
id={labelId}
aria-live={i === 0 ? "polite" : undefined}
className="text-sm font-medium tabular-nums"
>
{monthFormat.format(m)}
</h2>
{i === count - 1 ? (
<button
type="button"
aria-label="Next month"
disabled={nextDisabled}
onClick={() => setMonth(addMonths(month, 1))}
className={navButton}
>
<ChevronRight className="h-4 w-4" />
</button>
) : (
<span aria-hidden className="size-8" />
)}
</div>
<div className="relative overflow-hidden">
<AnimatePresence
initial={false}
mode="popLayout"
custom={direction}
>
<motion.table
key={mk}
data-month={mk}
custom={direction}
aria-labelledby={ariaLabel ? undefined : labelId}
aria-label={ariaLabel ? `${ariaLabel}, ${monthFormat.format(m)}` : undefined}
onKeyDown={onGridKeyDown}
variants={{
enter: (dir: number) => ({
opacity: 0,
x: reduce ? 0 : dir * 28,
filter: reduce ? "blur(0px)" : "blur(4px)",
}),
center: {
opacity: 1,
x: 0,
filter: "blur(0px)",
transition: MONTH_ENTER,
},
exit: (dir: number) => ({
opacity: 0,
x: reduce ? 0 : dir * -28,
filter: reduce ? "blur(0px)" : "blur(4px)",
transition: MONTH_EXIT,
}),
}}
initial="enter"
animate="center"
exit="exit"
className="border-collapse"
>
<thead>
<tr>
{showWeekNumber && (
<th
scope="col"
abbr="Week"
className="h-8 w-8 text-[11px] font-medium text-muted-foreground/70"
>
Wk
</th>
)}
{weekdays.map((w) => (
<th
key={w.long}
scope="col"
abbr={w.long}
className={cn(
"h-8 text-[11px] font-medium text-muted-foreground",
tall ? "w-10" : "w-9",
)}
>
{w.short}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((row) => {
// Days this row actually draws: side-by-side months
// leave the other month's days blank.
const shown =
count > 1 ? row.filter((d) => sameMonth(d, m)) : row;
const weekSelected =
!!week && sameDay(row[0], weekStart(week.from));
const weekPreview =
!weekSelected && sameDay(row[0], previewWeek);
// ISO weeks run Monday–Sunday; the row's Thursday
// decides which one a differently started row is.
const thursday =
row.find((d) => d.getDay() === 4) ?? row[0];
return (
<tr key={dayKey(row[0])}>
{showWeekNumber && (
<th
scope="row"
className={cn(
"p-0 text-center text-[11px] font-normal tabular-nums transition-colors",
weekSelected
? "font-medium text-foreground"
: "text-muted-foreground/70",
)}
>
{shown.length > 0 ? isoWeek(thursday) : null}
</th>
)}
{row.map((d) => {
const outside = !sameMonth(d, m);
// Side-by-side months would show these days twice.
if (outside && count > 1)
return (
<td key={dayKey(d)} className="p-0">
<span className={cn("block", cellSize)} />
</td>
);
const meta = getDayMeta?.(d) ?? null;
const disabled = isDisabled(d);
const nightsBlocked =
!disabled && blockedByNights(d);
const isToday = sameDay(d, today);
const isFrom = sameDay(d, range?.from);
const isTo = sameDay(d, bandEnd);
const inBand =
!!range?.from &&
!!bandEnd &&
d > range.from &&
d < bandEnd;
const chosen = isMultiple && isPicked(d);
const inWeek =
!!week &&
d >= startOfDay(week.from) &&
d <= startOfDay(week.to);
const selected = isRange
? isFrom || (isTo && !!range?.to)
: isWeek
? inWeek
: isMultiple
? chosen
: sameDay(d, single);
const endpoint = isFrom || isTo;
// Picked neighbours in the same row merge into one
// pill, so a run of days reads as a block.
const joinPrev =
chosen &&
column(d) > 0 &&
sameMonth(addDays(d, -1), m) &&
isPicked(addDays(d, -1));
const joinNext =
chosen &&
column(d) < 6 &&
sameMonth(addDays(d, 1), m) &&
isPicked(addDays(d, 1));
// The week pill lives in the row's first drawn
// cell and spans every drawn day after it.
const weekPill =
isWeek &&
(weekSelected || weekPreview) &&
sameDay(d, shown[0]);
return (
<td key={dayKey(d)} className="relative p-0">
{(inBand ||
(endpoint &&
bandEnd &&
!sameDay(range?.from, bandEnd))) && (
<span
aria-hidden
className={cn(
"absolute inset-y-0.5 bg-primary/10",
inBand && "inset-x-0",
isFrom && "left-1/2 right-0",
isTo && "left-0 right-1/2",
)}
/>
)}
{weekPill && (
<motion.span
aria-hidden
// Selected and previewed rows each glide
// between weeks, scoped per month.
layoutId={`${uid}-${mk}-${weekSelected ? "week" : "week-preview"}`}
transition={
reduce ? { duration: 0 } : SPRING_LAYOUT
}
style={{ width: `${shown.length * 100}%` }}
className={cn(
"pointer-events-none absolute inset-y-0 left-0 rounded-lg",
weekSelected ? "bg-primary" : "bg-muted",
)}
/>
)}
<button
type="button"
data-date={dayKey(d)}
tabIndex={
sameDay(d, focusInMonth) && !outside
? 0
: -1
}
aria-label={
meta?.label
? `${dayLabel.format(d)}, ${meta.label}`
: dayLabel.format(d)
}
aria-pressed={selected}
aria-disabled={
disabled || nightsBlocked || undefined
}
aria-current={isToday ? "date" : undefined}
title={
nightsBlocked
? daysBetween(range?.from ?? d, d) <
minNights
? `Minimum ${minNights} nights`
: `Maximum ${maxNights} nights`
: undefined
}
onClick={() => select(d)}
onFocus={() => {
setFocused(d);
if (isWeek) setHovered(d);
}}
onBlur={() => isWeek && setHovered(null)}
onPointerEnter={() =>
(isRange || isWeek) && setHovered(d)
}
className={cn(
"relative isolate flex items-center justify-center rounded-lg text-sm tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
cellSize,
tall && "flex-col gap-0.5",
outside
? "text-muted-foreground/50"
: "text-foreground",
!selected &&
!disabled &&
!nightsBlocked &&
!isWeek &&
"hover:bg-muted",
selected && "text-primary-foreground",
selected &&
outside &&
"text-primary-foreground/60",
isTo && !range?.to && "text-foreground",
nightsBlocked &&
"cursor-not-allowed text-muted-foreground/40",
disabled &&
"cursor-not-allowed text-muted-foreground/40 line-through",
)}
>
{selected &&
!isWeek &&
(isRange || isMultiple ? (
<motion.span
aria-hidden
initial={
reduce ? false : { scale: 0.7 }
}
animate={{ scale: 1 }}
transition={SPRING_PRESS}
className={cn(
"absolute inset-0 -z-10 rounded-lg bg-primary",
joinPrev && "rounded-l-none",
joinNext && "rounded-r-none",
)}
/>
) : (
<motion.span
aria-hidden
// Scoped per month so the pill glides
// between days but never across the
// sliding grids.
layoutId={`${uid}-${mk}-selected`}
transition={
reduce
? { duration: 0 }
: SPRING_LAYOUT
}
className="absolute inset-0 -z-10 rounded-lg bg-primary"
/>
))}
{isTo && !range?.to && (
<span
aria-hidden
className="absolute inset-0 -z-10 rounded-lg border border-primary/40"
/>
)}
{tall ? (
<span className="leading-none">
{d.getDate()}
</span>
) : (
d.getDate()
)}
{tall && (
<span
aria-hidden
className={cn(
"h-2.5 text-[10px] leading-none",
meta?.tone === "accent"
? // Tinted toward the foreground so
// the green keeps contrast on
// light and dark cards.
"font-medium text-[color:color-mix(in_oklab,var(--chart-3)_70%,var(--foreground))]"
: meta?.tone === "muted"
? "text-muted-foreground/60"
: "text-muted-foreground",
selected &&
"text-primary-foreground/75",
(disabled || nightsBlocked) &&
"text-muted-foreground/40",
)}
>
{meta?.label}
</span>
)}
{isToday && (
<span
aria-hidden
className={cn(
"absolute size-1 rounded-full",
tall ? "top-1.5 right-1.5" : "bottom-1",
selected
? "bg-primary-foreground"
: "bg-primary",
)}
/>
)}
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</motion.table>
</AnimatePresence>
</div>
</div>
);
})}
</div>
</div>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
/** The element the panel measures against — see `registerTrigger`. */
triggerRef: React.MutableRefObject<HTMLElement | null>;
registerTrigger: (node: HTMLElement | null) => void;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
// A trigger normally registers itself through MorphPopoverTrigger. It can't
// when something else already clones the element — a Tooltip wrapping the
// button, say — and an unregistered trigger leaves the panel with nothing to
// measure against, so it renders permanently invisible. The root boxes the
// trigger exactly (the content portals out of it), so it stands in until a
// real trigger registers, and stands in again if that one unmounts. Both are
// state, so a trigger arriving while the panel is open re-anchors it.
const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(
() => ({ current: trigger ?? root }),
[root, trigger],
);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: a dismissal hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded. When no trigger has
// registered, the root anchor stands in only if it can actually hold focus;
// there is nowhere better than where the keyboard already is, so leave it.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (!inPanel) return;
const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);
restore?.focus();
}, [root, setOpen, trigger]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
root &&
!root.contains(target) &&
!contentRef.current?.contains(target)
)
close();
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, root, close]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef: anchorRef,
registerTrigger: setTrigger,
contentRef,
}),
[open, setOpen, toggle, baseId, anchorRef],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={setRoot} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as React.MutableRefObject<T | null>).current = node;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergeRefs(childRef, ctx.registerTrigger),
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipHidden(side: Side, align: Align, radius: number) {
const top = side === "bottom" ? "0%" : "92%";
const bottom = side === "bottom" ? "92%" : "0%";
const right = align === "end" ? "0%" : "92%";
const left = align === "end" ? "92%" : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
const clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
className?: string;
}
export function MorphPopoverContent({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [portalReady, setPortalReady] = useState(false);
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
portalReady && ctx.open,
);
useEffect(() => setPortalReady(true), []);
const left = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
const top = layout
? side === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: layout.trigger.top - layout.content.height - sideOffset
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },
show: { opacity: 1, scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipHidden(side, align, radius),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipShown(radius),
transition: MORPH_CLIP_TRANSITION,
},
};
// Keep the server and first client render identical, then mount the portal.
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open ? (
<motion.div
data-morph-popover-portal=""
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "show"}
exit={reduce ? { opacity: 0 } : "hidden"}
transition={reduce ? { duration: 0.12 } : undefined}
style={{
left,
top,
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(side, align),
}}
className="fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]"
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role="dialog"
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
API Reference
value?DateRange | null—defaultValue?DateRange | nullnullonValueChange?((range: { from: Date; to: Date; }) => void)Fires once both ends are picked (or a preset is chosen).
—placeholder?stringPick a date rangepresets?DateRangePreset[]Quick picks listed beside the calendar.
—numberOfMonths?numberMonths side by side. Default 2 (stacked below `sm`).
2formatOptions?DateTimeFormatOptionsIntl options for the trigger label. Default: "Oct 14 – 20, 2026".
{
month: "short",
day: "numeric",
year: "numeric",
}locale?stringDefault "en-US".
en-USminDate?Date—maxDate?Date—isDateDisabled?((date: Date) => boolean)—weekStartsOn?0 | 1 | 2 | 3 | 4 | 5 | 6—align?"end" | "start"startdisabled?boolean—className?string—aria-label?stringChoose date rangeMultiple Dates
calendar.tsxToggle any number of separate days up to a max; picked neighbours in a week merge into one pill.
API Reference
minDate?DateEarliest selectable day (inclusive).
—maxDate?DateLatest selectable day (inclusive).
—isDateDisabled?((date: Date) => boolean)Extra per-day rule, e.g. weekends or booked dates.
—weekStartsOn?0 | 1 | 2 | 3 | 4 | 5 | 60 = Sunday … 6 = Saturday. Default 0.
—locale?stringBCP 47 locale for month, weekday and day labels. Default "en-US" — a fixed default keeps server and client output identical for hydration.
—month?DateControlled visible month (any day inside it).
—defaultMonth?DateUncontrolled initial visible month. Default: the selection or today.
—onMonthChange?((month: Date) => void)—numberOfMonths?numberMonths shown side by side (stacked below `sm`). Default 1. With more than one, days outside each month are left blank so none repeats.
—showWeekNumber?booleanAdds an ISO 8601 week-number column ("Wk") before each row.
—getDayMeta?((date: Date) => CalendarDayMeta | null)Per-day label, tone and blocking, e.g. nightly prices or booked days. Passing it switches the grid to taller cells that fit the label.
—className?string—aria-label?string—mode?"multiple" | "range" | "week" | "single"—value?Date | DateRange | WeekRange | Date[] | null—defaultValue?Date | DateRange | WeekRange | Date[] | null—onValueChange?((date: Date) => void) | ((range: DateRange) => void) | ((week: WeekRange) => void) | ((dates: Date[]) => void)Called with the picked day's whole row, honouring `weekStartsOn`. Called with the selection sorted by date.
—minNights?numberFewest nights (days between start and end) a finished range may span. While the end is being chosen, days too close are blocked.
—maxNights?numberMost nights a finished range may span; farther days are blocked.
—max?numberMost days that can be picked; further picks are ignored.
—Week Picker
calendar.tsxHovering a day previews its whole week and a click picks it as one pill across the row, with an ISO week-number column; shown as a timesheet week.
API Reference
minDate?DateEarliest selectable day (inclusive).
—maxDate?DateLatest selectable day (inclusive).
—isDateDisabled?((date: Date) => boolean)Extra per-day rule, e.g. weekends or booked dates.
—weekStartsOn?0 | 1 | 2 | 3 | 4 | 5 | 60 = Sunday … 6 = Saturday. Default 0.
—locale?stringBCP 47 locale for month, weekday and day labels. Default "en-US" — a fixed default keeps server and client output identical for hydration.
—month?DateControlled visible month (any day inside it).
—defaultMonth?DateUncontrolled initial visible month. Default: the selection or today.
—onMonthChange?((month: Date) => void)—numberOfMonths?numberMonths shown side by side (stacked below `sm`). Default 1. With more than one, days outside each month are left blank so none repeats.
—showWeekNumber?booleanAdds an ISO 8601 week-number column ("Wk") before each row.
—getDayMeta?((date: Date) => CalendarDayMeta | null)Per-day label, tone and blocking, e.g. nightly prices or booked days. Passing it switches the grid to taller cells that fit the label.
—className?string—aria-label?string—mode?"multiple" | "range" | "week" | "single"—value?Date | DateRange | WeekRange | Date[] | null—defaultValue?Date | DateRange | WeekRange | Date[] | null—onValueChange?((date: Date) => void) | ((range: DateRange) => void) | ((week: WeekRange) => void) | ((dates: Date[]) => void)Called with the picked day's whole row, honouring `weekStartsOn`. Called with the selection sorted by date.
—minNights?numberFewest nights (days between start and end) a finished range may span. While the end is being chosen, days too close are blocked.
—maxNights?numberMost nights a finished range may span; farther days are blocked.
—max?numberMost days that can be picked; further picks are ignored.
—Booking Calendar
booking-calendar.tsxTwo-month stay picker with nightly prices under each day and the cheapest days tinted, booked nights struck through, a minimum stay, and a footer with dates, nights, total and a Reserve button.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/date-picker
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useMemo, useState } from "react";
import { Button } from "@/components/motion/button";
import {
Calendar,
type CalendarDayMeta,
type DateRange,
} from "@/components/motion/calendar";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type BookingSummary = {
from: Date;
to: Date;
nights: number;
/** Sum of the nightly prices, check-in night through the last night. */
total: number;
};
export interface BookingCalendarProps {
/** Nightly price keyed by "YYYY-MM-DD" (the night's check-in date), or a
* function. Nights without a price can't be booked. */
prices: Record<string, number> | ((date: Date) => number | null | undefined);
/** Nights already taken, as dates or "YYYY-MM-DD". They render struck
* through; the first one after a check-in still works as the check-out. */
booked?: (Date | string)[];
/** Fewest nights per stay. Default 1. */
minNights?: number;
/** Most nights per stay. */
maxNights?: number;
/** ISO 4217 code for prices and the total. Default "USD". */
currency?: string;
/** BCP 47 locale. Default "en-US" so server and client output match. */
locale?: string;
/** Earliest check-in, typically today (read it after mount). */
minDate?: Date;
maxDate?: Date;
value?: DateRange | null;
defaultValue?: DateRange | null;
onValueChange?: (range: DateRange | null) => void;
/** Uncontrolled first visible month. */
defaultMonth?: Date;
/** Months side by side. Default 2. */
numberOfMonths?: number;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Called by the Reserve button with the finished stay. */
onReserve?: (booking: BookingSummary) => void;
/** Reserve button text. Default "Reserve". */
reserveLabel?: string;
className?: string;
}
const dayKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const addDays = (d: Date, n: number) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
const sameDay = (a: Date, b: Date) => dayKey(a) === dayKey(b);
const nightsBetween = (a: Date, b: Date) =>
Math.round(
(new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime() -
new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime()) /
86_400_000,
);
const toDate = (d: Date | string) => {
if (d instanceof Date) return d;
const [y, m, day] = d.split("-").map(Number);
return new Date(y, m - 1, day);
};
/** Share of each month's open nights tinted as the cheapest. */
const CHEAP_SHARE = 0.25;
/** How far ahead to look for the next taken night after a check-in. */
const SCAN_LIMIT = 366;
const SWAP = { duration: 0.2, ease: EASE_OUT } as const;
/**
* Stay picker block: a two-month range `Calendar` with nightly prices under
* each day (each month's cheapest quarter tinted), booked nights struck
* through and unselectable, a stay can't run across a booked night, minimum
* and maximum nights, and a summary footer with dates, nights, the
* `Intl`-formatted total and a Reserve button. Controlled or uncontrolled.
*/
export function BookingCalendar({
prices,
booked,
minNights = 1,
maxNights,
currency = "USD",
locale = "en-US",
minDate,
maxDate,
value: controlledValue,
defaultValue = null,
onValueChange,
defaultMonth,
numberOfMonths = 2,
weekStartsOn = 0,
onReserve,
reserveLabel = "Reserve",
className,
}: BookingCalendarProps) {
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState<DateRange | null>(
defaultValue,
);
const value =
controlledValue !== undefined ? controlledValue : internalValue;
const setValue = (next: DateRange | null) => {
if (controlledValue === undefined) setInternalValue(next);
onValueChange?.(next);
};
const bookedKeys = useMemo(
() => new Set((booked ?? []).map((d) => dayKey(toDate(d)))),
[booked],
);
const priceOf = useCallback(
(d: Date) => {
const p = typeof prices === "function" ? prices(d) : prices[dayKey(d)];
return p ?? null;
},
[prices],
);
const nightOpen = useCallback(
(d: Date) => !bookedKeys.has(dayKey(d)) && priceOf(d) !== null,
[bookedKeys, priceOf],
);
// Cheapest-quarter threshold per month, computed once per month shown.
const cheapThreshold = useMemo(() => {
const cache = new Map<string, number>();
return (d: Date) => {
const key = dayKey(d).slice(0, 7);
const hit = cache.get(key);
if (hit !== undefined) return hit;
const open: number[] = [];
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
for (let i = 1; i <= last; i++) {
const day = new Date(d.getFullYear(), d.getMonth(), i);
const p = priceOf(day);
if (p !== null && !bookedKeys.has(dayKey(day))) open.push(p);
}
open.sort((a, b) => a - b);
const t = open.length
? open[Math.floor((open.length - 1) * CHEAP_SHARE)]
: Number.NEGATIVE_INFINITY;
cache.set(key, t);
return t;
};
}, [bookedKeys, priceOf]);
// While the check-out is being picked, the stay can't cross a taken night:
// the first one caps the length, and its day still works as the check-out.
const choosingEnd = !!value && !value.to;
const reach = useMemo(() => {
if (!value || value.to) return null;
const cap = maxNights ?? SCAN_LIMIT;
for (let n = 1; n <= cap; n++)
if (!nightOpen(addDays(value.from, n))) return n;
return cap;
}, [value, maxNights, nightOpen]);
const checkoutOnly =
choosingEnd && value && reach !== null && !nightOpen(addDays(value.from, reach))
? addDays(value.from, reach)
: null;
const priceLabel = useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
notation: "compact",
maximumFractionDigits: 0,
}),
[locale, currency],
);
const money = useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
maximumFractionDigits: 0,
}),
[locale, currency],
);
const shortDate = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
weekday: "short",
month: "short",
day: "numeric",
}),
[locale],
);
const getDayMeta = (d: Date): CalendarDayMeta => {
if (checkoutOnly && sameDay(d, checkoutOnly))
return { label: "Out", tone: "muted" };
const price = priceOf(d);
if (bookedKeys.has(dayKey(d)) || price === null) return { disabled: true };
return {
label: priceLabel.format(price),
tone: price <= cheapThreshold(d) ? "accent" : undefined,
};
};
const stay: BookingSummary | null = useMemo(() => {
if (!value?.to) return null;
const nights = nightsBetween(value.from, value.to);
let total = 0;
for (let i = 0; i < nights; i++) total += priceOf(addDays(value.from, i)) ?? 0;
return { from: value.from, to: value.to, nights, total };
}, [value, priceOf]);
const hint = stay
? `${shortDate.format(stay.from)} – ${shortDate.format(stay.to)}`
: value
? `Check-in ${shortDate.format(value.from)} · pick check-out`
: "Pick a check-in date";
const detail = stay
? `${stay.nights} night${stay.nights === 1 ? "" : "s"} · ${money.format(stay.total)}`
: minNights > 1
? `${minNights}-night minimum`
: "Prices per night";
return (
<div
className={cn(
"w-fit rounded-2xl border border-border bg-card text-foreground",
className,
)}
>
<Calendar
mode="range"
value={value}
onValueChange={setValue}
numberOfMonths={numberOfMonths}
minNights={minNights}
maxNights={reach ?? maxNights}
getDayMeta={getDayMeta}
minDate={minDate}
maxDate={maxDate}
defaultMonth={defaultMonth}
weekStartsOn={weekStartsOn}
locale={locale}
aria-label="Stay dates"
className="border-0 bg-transparent"
/>
<div className="flex items-center justify-between gap-4 border-t border-border px-4 py-3">
<div aria-live="polite" className="min-w-0">
<AnimatePresence initial={false} mode="popLayout">
<motion.p
key={hint}
initial={{ opacity: 0, y: reduce ? 0 : 6 }}
animate={{ opacity: 1, y: 0, transition: SWAP }}
exit={{ opacity: 0, y: reduce ? 0 : -6, transition: SWAP }}
className="truncate text-sm font-medium tabular-nums"
>
{hint}
</motion.p>
</AnimatePresence>
<p className="truncate text-xs text-muted-foreground tabular-nums">
{detail}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{value && (
<Button variant="ghost" size="sm" onClick={() => setValue(null)}>
Clear
</Button>
)}
<Button
size="sm"
disabled={!stay}
onClick={() => stay && onReserve?.(stay)}
>
{reserveLabel}
</Button>
</div>
</div>
</div>
);
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type DateRange = { from: Date; to?: Date };
/** A whole week row: `from` is its first day, `to` its seventh. */
export type WeekRange = { from: Date; to: Date };
/** Per-day extras from `getDayMeta`. */
export type CalendarDayMeta = {
/** Short text under the day number (a price, a count). Cells grow taller
* when any day has meta; the label is also read after the date. */
label?: string;
/** "accent" tints the label (deals, highlights); "muted" fades it. */
tone?: "muted" | "accent";
/** Blocks the day and strikes it through, like `isDateDisabled`. */
disabled?: boolean;
};
type CalendarBaseProps = {
/** Earliest selectable day (inclusive). */
minDate?: Date;
/** Latest selectable day (inclusive). */
maxDate?: Date;
/** Extra per-day rule, e.g. weekends or booked dates. */
isDateDisabled?: (date: Date) => boolean;
/** 0 = Sunday … 6 = Saturday. Default 0. */
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** BCP 47 locale for month, weekday and day labels. Default "en-US" — a
* fixed default keeps server and client output identical for hydration. */
locale?: string;
/** Controlled visible month (any day inside it). */
month?: Date;
/** Uncontrolled initial visible month. Default: the selection or today. */
defaultMonth?: Date;
onMonthChange?: (month: Date) => void;
/** Months shown side by side (stacked below `sm`). Default 1. With more
* than one, days outside each month are left blank so none repeats. */
numberOfMonths?: number;
/** Adds an ISO 8601 week-number column ("Wk") before each row. */
showWeekNumber?: boolean;
/** Per-day label, tone and blocking, e.g. nightly prices or booked days.
* Passing it switches the grid to taller cells that fit the label. */
getDayMeta?: (date: Date) => CalendarDayMeta | null | undefined;
className?: string;
"aria-label"?: string;
};
export type CalendarSingleProps = CalendarBaseProps & {
mode?: "single";
value?: Date | null;
defaultValue?: Date | null;
onValueChange?: (date: Date) => void;
};
export type CalendarRangeProps = CalendarBaseProps & {
mode: "range";
value?: DateRange | null;
defaultValue?: DateRange | null;
onValueChange?: (range: DateRange) => void;
/** Fewest nights (days between start and end) a finished range may span.
* While the end is being chosen, days too close are blocked. */
minNights?: number;
/** Most nights a finished range may span; farther days are blocked. */
maxNights?: number;
};
export type CalendarWeekProps = CalendarBaseProps & {
mode: "week";
value?: WeekRange | null;
defaultValue?: WeekRange | null;
/** Called with the picked day's whole row, honouring `weekStartsOn`. */
onValueChange?: (week: WeekRange) => void;
};
export type CalendarMultipleProps = CalendarBaseProps & {
mode: "multiple";
value?: Date[] | null;
defaultValue?: Date[] | null;
/** Called with the selection sorted by date. */
onValueChange?: (dates: Date[]) => void;
/** Most days that can be picked; further picks are ignored. */
max?: number;
};
export type CalendarProps =
| CalendarSingleProps
| CalendarRangeProps
| CalendarWeekProps
| CalendarMultipleProps;
// ── date helpers (local time, day precision) ──────────────────────────────
const startOfDay = (d: Date) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate());
const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
const addDays = (d: Date, n: number) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
/** Adds months, clamping the day so Jan 31 + 1 month lands on Feb 28/29. */
function addMonths(d: Date, n: number) {
const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
const lastDay = new Date(
target.getFullYear(),
target.getMonth() + 1,
0,
).getDate();
target.setDate(Math.min(d.getDate(), lastDay));
return target;
}
const sameDay = (a?: Date | null, b?: Date | null) =>
!!a &&
!!b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
const sameMonth = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
const dayKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const monthKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
/** Whole days from `a` to `b`; rounding absorbs DST hour shifts. */
const daysBetween = (a: Date, b: Date) =>
Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / 86_400_000);
/** ISO 8601 week number: weeks start Monday, week 1 holds Jan 4. */
function isoWeek(d: Date) {
const thursday = addDays(d, 3 - ((d.getDay() + 6) % 7));
const jan4 = new Date(thursday.getFullYear(), 0, 4);
const firstThursday = addDays(jan4, 3 - ((jan4.getDay() + 6) % 7));
return 1 + Math.round(daysBetween(firstThursday, thursday) / 7);
}
/** Always 6 weeks, so the panel never changes height between months. */
function buildWeeks(month: Date, weekStartsOn: number) {
const first = startOfMonth(month);
const offset = (first.getDay() - weekStartsOn + 7) % 7;
const start = addDays(first, -offset);
return Array.from({ length: 6 }, (_, w) =>
Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),
);
}
// Month swap is a short directional slide; the exit is quicker than the entry.
const MONTH_ENTER = { duration: 0.26, ease: EASE_OUT } as const;
const MONTH_EXIT = { duration: 0.16, ease: EASE_OUT } as const;
/**
* Month-grid calendar for single dates, ranges, whole weeks or several
* separate days, one or more months wide. The selected day is a pill that
* glides between days on a shared-layout spring; a picked week is one pill
* across its row; months slide in from the direction you travel. Optional ISO
* week numbers, per-day labels (`getDayMeta`) and night limits for ranges.
* Full keyboard grid navigation (arrows, Home/End, PageUp/PageDown,
* Shift+Page for years). Controlled or uncontrolled for both the selection
* and the visible month; reduced-motion safe.
*/
export function Calendar(props: CalendarProps) {
const {
minDate,
maxDate,
isDateDisabled,
weekStartsOn = 0,
locale = "en-US",
month: controlledMonth,
defaultMonth,
onMonthChange,
numberOfMonths = 1,
showWeekNumber = false,
getDayMeta,
className,
"aria-label": ariaLabel,
} = props;
const isRange = props.mode === "range";
const isWeek = props.mode === "week";
const isMultiple = props.mode === "multiple";
const count = Math.max(1, Math.floor(numberOfMonths));
const minNights = isRange ? ((props as CalendarRangeProps).minNights ?? 0) : 0;
const maxNights = isRange ? (props as CalendarRangeProps).maxNights : undefined;
// Taller cells only when there is something to put under the number.
const tall = !!getDayMeta;
const uid = useId();
const reduce = useReducedMotion();
// ── selection ──
const [internalValue, setInternalValue] = useState<
Date | DateRange | WeekRange | Date[] | null
>(props.defaultValue ?? null);
const valueControlled = props.value !== undefined;
const value = valueControlled ? (props.value ?? null) : internalValue;
const single =
isRange || isWeek || isMultiple ? null : (value as Date | null);
const range = isRange ? (value as DateRange | null) : null;
const week = isWeek ? (value as WeekRange | null) : null;
const picked = isMultiple ? ((value as Date[] | null) ?? []) : [];
const isPicked = (d: Date) => picked.some((p) => sameDay(p, d));
// ── visible months ──
const anchor = single ?? range?.from ?? week?.from ?? picked[0] ?? null;
const [internalMonth, setInternalMonth] = useState(() =>
startOfMonth(defaultMonth ?? anchor ?? new Date()),
);
const month = controlledMonth
? startOfMonth(controlledMonth)
: internalMonth;
const [direction, setDirection] = useState(1);
const lastMonth = addMonths(month, count - 1);
const inWindow = (d: Date) => {
const m = startOfMonth(d);
return m >= month && m <= lastMonth;
};
const setMonth = useCallback(
(next: Date) => {
const m = startOfMonth(next);
if (sameMonth(m, month)) return;
setDirection(m > month ? 1 : -1);
if (!controlledMonth) setInternalMonth(m);
onMonthChange?.(m);
},
[month, controlledMonth, onMonthChange],
);
// "Today" is read after mount: the server's clock and timezone can disagree
// with the viewer's, and a mismatched marker would break hydration.
const [today, setToday] = useState<Date | null>(null);
useEffect(() => setToday(startOfDay(new Date())), []);
// ── roving focus ──
const [focused, setFocused] = useState<Date>(() =>
startOfDay(anchor ?? defaultMonth ?? new Date()),
);
const gridRef = useRef<HTMLDivElement>(null);
const shouldFocus = useRef(false);
// Range hover preview: the band follows the pointer until the end is picked.
// In week mode it is the row under the pointer or keyboard focus.
const [hovered, setHovered] = useState<Date | null>(null);
// Keep the tab stop inside the visible months when they change by buttons.
const focusInMonth = inWindow(focused)
? focused
: anchor && inWindow(anchor)
? anchor
: month;
useEffect(() => {
if (!shouldFocus.current) return;
shouldFocus.current = false;
gridRef.current
?.querySelector<HTMLButtonElement>(
`[data-month="${monthKey(focusInMonth)}"] [data-date="${dayKey(focusInMonth)}"]`,
)
?.focus();
}, [focusInMonth]);
const isDisabled = useCallback(
(d: Date) =>
(minDate && d < startOfDay(minDate)) ||
(maxDate && d > startOfDay(maxDate)) ||
!!isDateDisabled?.(d) ||
!!getDayMeta?.(d)?.disabled,
[minDate, maxDate, isDateDisabled, getDayMeta],
);
// While a range end is being chosen, days that would make it too short or
// too long are blocked (earlier days stay open: they restart the range).
const choosingEnd = !!range && !range.to;
const nightsAllowed = (n: number) =>
n >= minNights && (maxNights === undefined || n <= maxNights);
const blockedByNights = (d: Date) =>
choosingEnd &&
!!range &&
d > range.from &&
!nightsAllowed(daysBetween(range.from, d));
const weekStart = (d: Date) =>
addDays(d, -((d.getDay() - weekStartsOn + 7) % 7));
const select = (d: Date) => {
if (isDisabled(d) || blockedByNights(d)) return;
if (!inWindow(d)) setMonth(d);
setFocused(d);
if (isWeek) {
const from = weekStart(d);
const next: WeekRange = { from, to: addDays(from, 6) };
if (!valueControlled) setInternalValue(next);
(props as CalendarWeekProps).onValueChange?.(next);
return;
}
if (isMultiple) {
const max = (props as CalendarMultipleProps).max;
const next = isPicked(d)
? picked.filter((p) => !sameDay(p, d))
: max !== undefined && picked.length >= max
? null
: [...picked, d].sort((a, b) => a.getTime() - b.getTime());
if (!next) return;
if (!valueControlled) setInternalValue(next);
(props as CalendarMultipleProps).onValueChange?.(next);
return;
}
if (!isRange) {
if (!valueControlled) setInternalValue(d);
(props as CalendarSingleProps).onValueChange?.(d);
return;
}
// First click starts a range; a later day closes it; an earlier day, the
// start again when it would be too short, or a click on a finished range
// starts over.
const next: DateRange =
range &&
!range.to &&
d >= range.from &&
nightsAllowed(daysBetween(range.from, d))
? { from: range.from, to: d }
: { from: d };
if (!valueControlled) setInternalValue(next);
(props as CalendarRangeProps).onValueChange?.(next);
};
const onGridKeyDown = (e: KeyboardEvent) => {
const base = focusInMonth;
let next: Date | null = null;
switch (e.key) {
case "ArrowLeft":
next = addDays(base, -1);
break;
case "ArrowRight":
next = addDays(base, 1);
break;
case "ArrowUp":
next = addDays(base, -7);
break;
case "ArrowDown":
next = addDays(base, 7);
break;
case "Home":
next = addDays(base, -((base.getDay() - weekStartsOn + 7) % 7));
break;
case "End":
next = addDays(base, 6 - ((base.getDay() - weekStartsOn + 7) % 7));
break;
case "PageUp":
next = addMonths(base, e.shiftKey ? -12 : -1);
break;
case "PageDown":
next = addMonths(base, e.shiftKey ? 12 : 1);
break;
default:
return;
}
e.preventDefault();
shouldFocus.current = true;
setFocused(next);
// Scroll the window only when focus leaves it, by as little as needed.
if (!inWindow(next))
setMonth(next < month ? next : addMonths(startOfMonth(next), 1 - count));
};
// ── labels ──
const monthFormat = useMemo(
() => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
[locale],
);
const weekdays = useMemo(() => {
const short = new Intl.DateTimeFormat(locale, { weekday: "short" });
const long = new Intl.DateTimeFormat(locale, { weekday: "long" });
// 2023-01-01 was a Sunday.
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7));
return { short: short.format(d).slice(0, 2), long: long.format(d) };
});
}, [locale, weekStartsOn]);
const dayLabel = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
}),
[locale],
);
const prevDisabled =
!!minDate && startOfMonth(minDate) >= month;
const nextDisabled =
!!maxDate && startOfMonth(maxDate) <= lastMonth;
// Range band end: the picked end, or the hovered day while choosing one.
// A day that can't close the range doesn't stretch the band to it.
const bandEnd =
range &&
!range.to &&
hovered &&
hovered > range.from &&
!isDisabled(hovered) &&
!blockedByNights(hovered)
? hovered
: range?.to;
// Week mode: the row being previewed (pointer or keyboard focus).
const previewWeek = isWeek && hovered ? weekStart(hovered) : null;
const navButton =
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40";
const months = Array.from({ length: count }, (_, i) => addMonths(month, i));
// Weekday index of the column, so connecting bands stop at the row ends.
const column = (d: Date) => (d.getDay() - weekStartsOn + 7) % 7;
const cellSize = tall ? "h-12 w-10" : "size-9";
return (
<div
className={cn(
"w-fit rounded-2xl border border-border bg-card p-3 text-foreground",
className,
)}
>
<div
ref={gridRef}
onPointerLeave={() => setHovered(null)}
className="flex flex-col gap-x-6 gap-y-4 sm:flex-row"
>
{months.map((m, i) => {
const mk = monthKey(m);
const weeks = buildWeeks(m, weekStartsOn);
const labelId = `${uid}-label-${i}`;
return (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: columns are positional; each keeps its own month transition.
key={i}
className="min-w-0"
>
<div className="mb-2 flex items-center justify-between gap-2 px-1">
{i === 0 ? (
<button
type="button"
aria-label="Previous month"
disabled={prevDisabled}
onClick={() => setMonth(addMonths(month, -1))}
className={navButton}
>
<ChevronLeft className="h-4 w-4" />
</button>
) : (
<span aria-hidden className="size-8" />
)}
<h2
id={labelId}
aria-live={i === 0 ? "polite" : undefined}
className="text-sm font-medium tabular-nums"
>
{monthFormat.format(m)}
</h2>
{i === count - 1 ? (
<button
type="button"
aria-label="Next month"
disabled={nextDisabled}
onClick={() => setMonth(addMonths(month, 1))}
className={navButton}
>
<ChevronRight className="h-4 w-4" />
</button>
) : (
<span aria-hidden className="size-8" />
)}
</div>
<div className="relative overflow-hidden">
<AnimatePresence
initial={false}
mode="popLayout"
custom={direction}
>
<motion.table
key={mk}
data-month={mk}
custom={direction}
aria-labelledby={ariaLabel ? undefined : labelId}
aria-label={ariaLabel ? `${ariaLabel}, ${monthFormat.format(m)}` : undefined}
onKeyDown={onGridKeyDown}
variants={{
enter: (dir: number) => ({
opacity: 0,
x: reduce ? 0 : dir * 28,
filter: reduce ? "blur(0px)" : "blur(4px)",
}),
center: {
opacity: 1,
x: 0,
filter: "blur(0px)",
transition: MONTH_ENTER,
},
exit: (dir: number) => ({
opacity: 0,
x: reduce ? 0 : dir * -28,
filter: reduce ? "blur(0px)" : "blur(4px)",
transition: MONTH_EXIT,
}),
}}
initial="enter"
animate="center"
exit="exit"
className="border-collapse"
>
<thead>
<tr>
{showWeekNumber && (
<th
scope="col"
abbr="Week"
className="h-8 w-8 text-[11px] font-medium text-muted-foreground/70"
>
Wk
</th>
)}
{weekdays.map((w) => (
<th
key={w.long}
scope="col"
abbr={w.long}
className={cn(
"h-8 text-[11px] font-medium text-muted-foreground",
tall ? "w-10" : "w-9",
)}
>
{w.short}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((row) => {
// Days this row actually draws: side-by-side months
// leave the other month's days blank.
const shown =
count > 1 ? row.filter((d) => sameMonth(d, m)) : row;
const weekSelected =
!!week && sameDay(row[0], weekStart(week.from));
const weekPreview =
!weekSelected && sameDay(row[0], previewWeek);
// ISO weeks run Monday–Sunday; the row's Thursday
// decides which one a differently started row is.
const thursday =
row.find((d) => d.getDay() === 4) ?? row[0];
return (
<tr key={dayKey(row[0])}>
{showWeekNumber && (
<th
scope="row"
className={cn(
"p-0 text-center text-[11px] font-normal tabular-nums transition-colors",
weekSelected
? "font-medium text-foreground"
: "text-muted-foreground/70",
)}
>
{shown.length > 0 ? isoWeek(thursday) : null}
</th>
)}
{row.map((d) => {
const outside = !sameMonth(d, m);
// Side-by-side months would show these days twice.
if (outside && count > 1)
return (
<td key={dayKey(d)} className="p-0">
<span className={cn("block", cellSize)} />
</td>
);
const meta = getDayMeta?.(d) ?? null;
const disabled = isDisabled(d);
const nightsBlocked =
!disabled && blockedByNights(d);
const isToday = sameDay(d, today);
const isFrom = sameDay(d, range?.from);
const isTo = sameDay(d, bandEnd);
const inBand =
!!range?.from &&
!!bandEnd &&
d > range.from &&
d < bandEnd;
const chosen = isMultiple && isPicked(d);
const inWeek =
!!week &&
d >= startOfDay(week.from) &&
d <= startOfDay(week.to);
const selected = isRange
? isFrom || (isTo && !!range?.to)
: isWeek
? inWeek
: isMultiple
? chosen
: sameDay(d, single);
const endpoint = isFrom || isTo;
// Picked neighbours in the same row merge into one
// pill, so a run of days reads as a block.
const joinPrev =
chosen &&
column(d) > 0 &&
sameMonth(addDays(d, -1), m) &&
isPicked(addDays(d, -1));
const joinNext =
chosen &&
column(d) < 6 &&
sameMonth(addDays(d, 1), m) &&
isPicked(addDays(d, 1));
// The week pill lives in the row's first drawn
// cell and spans every drawn day after it.
const weekPill =
isWeek &&
(weekSelected || weekPreview) &&
sameDay(d, shown[0]);
return (
<td key={dayKey(d)} className="relative p-0">
{(inBand ||
(endpoint &&
bandEnd &&
!sameDay(range?.from, bandEnd))) && (
<span
aria-hidden
className={cn(
"absolute inset-y-0.5 bg-primary/10",
inBand && "inset-x-0",
isFrom && "left-1/2 right-0",
isTo && "left-0 right-1/2",
)}
/>
)}
{weekPill && (
<motion.span
aria-hidden
// Selected and previewed rows each glide
// between weeks, scoped per month.
layoutId={`${uid}-${mk}-${weekSelected ? "week" : "week-preview"}`}
transition={
reduce ? { duration: 0 } : SPRING_LAYOUT
}
style={{ width: `${shown.length * 100}%` }}
className={cn(
"pointer-events-none absolute inset-y-0 left-0 rounded-lg",
weekSelected ? "bg-primary" : "bg-muted",
)}
/>
)}
<button
type="button"
data-date={dayKey(d)}
tabIndex={
sameDay(d, focusInMonth) && !outside
? 0
: -1
}
aria-label={
meta?.label
? `${dayLabel.format(d)}, ${meta.label}`
: dayLabel.format(d)
}
aria-pressed={selected}
aria-disabled={
disabled || nightsBlocked || undefined
}
aria-current={isToday ? "date" : undefined}
title={
nightsBlocked
? daysBetween(range?.from ?? d, d) <
minNights
? `Minimum ${minNights} nights`
: `Maximum ${maxNights} nights`
: undefined
}
onClick={() => select(d)}
onFocus={() => {
setFocused(d);
if (isWeek) setHovered(d);
}}
onBlur={() => isWeek && setHovered(null)}
onPointerEnter={() =>
(isRange || isWeek) && setHovered(d)
}
className={cn(
"relative isolate flex items-center justify-center rounded-lg text-sm tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
cellSize,
tall && "flex-col gap-0.5",
outside
? "text-muted-foreground/50"
: "text-foreground",
!selected &&
!disabled &&
!nightsBlocked &&
!isWeek &&
"hover:bg-muted",
selected && "text-primary-foreground",
selected &&
outside &&
"text-primary-foreground/60",
isTo && !range?.to && "text-foreground",
nightsBlocked &&
"cursor-not-allowed text-muted-foreground/40",
disabled &&
"cursor-not-allowed text-muted-foreground/40 line-through",
)}
>
{selected &&
!isWeek &&
(isRange || isMultiple ? (
<motion.span
aria-hidden
initial={
reduce ? false : { scale: 0.7 }
}
animate={{ scale: 1 }}
transition={SPRING_PRESS}
className={cn(
"absolute inset-0 -z-10 rounded-lg bg-primary",
joinPrev && "rounded-l-none",
joinNext && "rounded-r-none",
)}
/>
) : (
<motion.span
aria-hidden
// Scoped per month so the pill glides
// between days but never across the
// sliding grids.
layoutId={`${uid}-${mk}-selected`}
transition={
reduce
? { duration: 0 }
: SPRING_LAYOUT
}
className="absolute inset-0 -z-10 rounded-lg bg-primary"
/>
))}
{isTo && !range?.to && (
<span
aria-hidden
className="absolute inset-0 -z-10 rounded-lg border border-primary/40"
/>
)}
{tall ? (
<span className="leading-none">
{d.getDate()}
</span>
) : (
d.getDate()
)}
{tall && (
<span
aria-hidden
className={cn(
"h-2.5 text-[10px] leading-none",
meta?.tone === "accent"
? // Tinted toward the foreground so
// the green keeps contrast on
// light and dark cards.
"font-medium text-[color:color-mix(in_oklab,var(--chart-3)_70%,var(--foreground))]"
: meta?.tone === "muted"
? "text-muted-foreground/60"
: "text-muted-foreground",
selected &&
"text-primary-foreground/75",
(disabled || nightsBlocked) &&
"text-muted-foreground/40",
)}
>
{meta?.label}
</span>
)}
{isToday && (
<span
aria-hidden
className={cn(
"absolute size-1 rounded-full",
tall ? "top-1.5 right-1.5" : "bottom-1",
selected
? "bg-primary-foreground"
: "bg-primary",
)}
/>
)}
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</motion.table>
</AnimatePresence>
</div>
</div>
);
})}
</div>
</div>
);
}
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"relative inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
// The ripple layer clips itself, so on a touch screen the button can
// stop clipping and let its enlarged hit area overhang.
ripple && "overflow-hidden pointer-coarse:overflow-visible",
TOUCH_TARGET_CLASS,
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"relative inline-flex items-center justify-center font-medium select-none",
"transition-colors",
TOUCH_TARGET_CLASS,
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API Reference
pricesRecord<string, number> | ((date: Date) => number | null | undefined)Nightly price keyed by "YYYY-MM-DD" (the night's check-in date), or a function. Nights without a price can't be booked.
—booked?(string | Date)[]Nights already taken, as dates or "YYYY-MM-DD". They render struck through; the first one after a check-in still works as the check-out.
—minNights?numberFewest nights per stay. Default 1.
1maxNights?numberMost nights per stay.
—currency?stringISO 4217 code for prices and the total. Default "USD".
USDlocale?stringBCP 47 locale. Default "en-US" so server and client output match.
en-USminDate?DateEarliest check-in, typically today (read it after mount).
—maxDate?Date—value?DateRange | null—defaultValue?DateRange | nullnullonValueChange?((range: DateRange | null) => void)—defaultMonth?DateUncontrolled first visible month.
—numberOfMonths?numberMonths side by side. Default 2.
2weekStartsOn?0 | 1 | 2 | 3 | 4 | 5 | 60onReserve?((booking: BookingSummary) => void)Called by the Reserve button with the finished stay.
—reserveLabel?stringReserve button text. Default "Reserve".
ReserveclassName?string—Time Picker
time-picker.tsxiOS-style hour, minute and AM/PM drums composed from WheelPicker, inline or as a time field whose wheels morph open and commit on Done.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Synthesizes a short, dry mechanical detent for gesture feedback (e.g. the
// WheelPicker row-cross click) entirely at runtime. Several inharmonic modes
// make it read as a tiny physical impact instead of an oscillator beep, while
// the high-passed transient gives it definition through phone speakers.
const DURATION = 0.016; // seconds, short enough to stay distinct during a fling
const MASTER_GAIN = 0.055;
const MODES = [
{ frequency: 980, decay: 0.0038, gain: 0.5, phase: 0 },
{ frequency: 1820, decay: 0.0024, gain: 0.3, phase: 0.65 },
{ frequency: 3160, decay: 0.00115, gain: 0.12, phase: 1.3 },
] as const;
type AudioContextCtor = typeof AudioContext;
function getAudioContextCtor(): AudioContextCtor | undefined {
if (typeof window === "undefined") return undefined;
return (
window.AudioContext ??
(window as unknown as { webkitAudioContext?: AudioContextCtor })
.webkitAudioContext
);
}
// One shared context/buffer/gain for the whole page, ref-counted across every
// `createTickPlayer()` caller. Components like WheelPicker commonly compose
// several side by side (a date made of month/day/year drums) — giving each
// its own AudioContext would waste resources and risk hitting a browser's cap
// on concurrent contexts. The context only closes once every consumer that
// created a player has called `dispose()`.
let ctx: AudioContext | null = null;
let masterGain: GainNode | null = null;
let clickBuffer: AudioBuffer | null = null;
let consumers = 0;
function buildClickBuffer(context: AudioContext): AudioBuffer {
const sampleRate = context.sampleRate;
const length = Math.ceil(DURATION * sampleRate);
const buffer = context.createBuffer(1, length, sampleRate);
const data = buffer.getChannelData(0);
let peak = 0;
let previousNoise = 0;
for (let i = 0; i < length; i++) {
const t = i / sampleRate;
const attack = Math.min(1, t / 0.00012);
const fadeOut = Math.min(1, (DURATION - t) / 0.001);
let resonance = 0;
for (const mode of MODES) {
resonance +=
Math.sin(2 * Math.PI * mode.frequency * t + mode.phase) *
Math.exp(-t / mode.decay) *
mode.gain;
}
// Differencing consecutive noise samples removes the low, papery part of
// white noise and leaves only a tiny impact at the front of the sound.
const noise = Math.random() * 2 - 1;
const transient =
(noise - previousNoise) * Math.exp(-t / 0.00038) * 0.035;
previousNoise = noise;
data[i] = (resonance + transient) * attack * fadeOut;
peak = Math.max(peak, Math.abs(data[i]));
}
if (peak > 0) {
const norm = 0.9 / peak;
for (let i = 0; i < length; i++) data[i] *= norm;
}
return buffer;
}
function ensureContext() {
if (ctx) return;
const Ctor = getAudioContextCtor();
if (!Ctor) return;
ctx = new Ctor();
masterGain = ctx.createGain();
masterGain.gain.value = MASTER_GAIN;
masterGain.connect(ctx.destination);
clickBuffer = buildClickBuffer(ctx);
}
function prepareContext() {
ensureContext();
if (ctx?.state === "suspended") {
// Browsers may reject this when it is not called from a trusted gesture.
// Pointer, touch, wheel and keyboard entry points call `prepare()` first;
// this repeat attempt keeps direct `play()` calls safe as well.
void ctx.resume().catch(() => undefined);
}
}
interface TickPlayer {
prepare: () => void;
play: () => void;
dispose: () => void;
}
/**
* Lazily-initialized tick player. No `AudioContext` is created until the first
* `prepare()` or `play()` call anywhere on the page. Every player shares the
* same context/buffer, ref-counted so `dispose()` only tears it down once the
* last consumer is gone. Safe during SSR or in browsers without Web Audio:
* `prepare()` and `play()` become silent no-ops.
*/
export function createTickPlayer(): TickPlayer {
consumers++;
let disposed = false;
let activeSource: AudioBufferSourceNode | null = null;
return {
prepare() {
if (disposed) return;
prepareContext();
},
play() {
if (disposed) return;
prepareContext();
if (!ctx || !masterGain || !clickBuffer) return;
// A fast fling can cross rows more quickly than the tail of a tick.
// Cutting that tail keeps the feedback dry instead of stacking into a
// louder metallic ring.
activeSource?.stop();
const source = ctx.createBufferSource();
source.buffer = clickBuffer;
source.connect(masterGain);
source.onended = () => {
source.disconnect();
if (activeSource === source) activeSource = null;
};
activeSource = source;
source.start();
},
dispose() {
if (disposed) return;
disposed = true;
activeSource?.stop();
activeSource = null;
consumers = Math.max(0, consumers - 1);
if (consumers === 0 && ctx) {
ctx.close();
ctx = null;
masterGain = null;
clickBuffer = null;
}
},
};
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/date-picker
import { Clock } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { WheelPicker } from "@/components/motion/wheel-picker";
import { cn } from "@/lib/utils";
/**
* A wall-clock time, always 24-hour internally: `hours` 0–23, `minutes` 0–59.
* The 12/24-hour choice is display only, so a value never changes meaning when
* `hourCycle` does. Use `parseTime` / `toTimeString` for "HH:mm" strings.
*/
export type TimeValue = { hours: number; minutes: number };
export type HourCycle = 12 | 24;
const pad = (n: number) => String(n).padStart(2, "0");
/** "09:30" → { hours: 9, minutes: 30 }. Returns null for anything malformed. */
export function parseTime(input: string): TimeValue | null {
const match = /^(\d{1,2}):(\d{2})$/.exec(input.trim());
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59) return null;
return { hours, minutes };
}
/** { hours: 9, minutes: 30 } → "09:30" (24-hour, zero padded). */
export function toTimeString(time: TimeValue) {
return `${pad(time.hours)}:${pad(time.minutes)}`;
}
/** Locale label for a time, e.g. "9:30 AM" (en-US, 12h) or "09:30" (24h). */
export function formatTime(
time: TimeValue,
{ locale = "en-US", hourCycle = 12 }: { locale?: string; hourCycle?: HourCycle } = {},
) {
// A fixed calendar day: only the clock fields are read, and a constant date
// keeps the output identical on server and client.
return new Intl.DateTimeFormat(locale, {
hour: "numeric",
minute: "2-digit",
hourCycle: hourCycle === 12 ? "h12" : "h23",
}).format(new Date(2000, 0, 1, time.hours, time.minutes));
}
/** Snaps minutes onto the step grid the minute wheel offers. */
function snapMinutes(minutes: number, step: number) {
const last = Math.floor(59 / step) * step;
return Math.min(Math.round(minutes / step) * step, last);
}
export interface TimePickerProps {
/** Controlled time (24-hour). */
value?: TimeValue;
/** Uncontrolled initial time. Default 09:00. */
defaultValue?: TimeValue;
/** Fires as each wheel settles on a row. Always 24-hour. */
onValueChange?: (time: TimeValue) => void;
/** 12 shows hour 12–11 plus an AM/PM wheel; 24 shows 00–23. Default 12. */
hourCycle?: HourCycle;
/** Minute wheel granularity. A value off the grid displays snapped to the
* nearest step and the first wheel turn commits the snapped minute. Default 5. */
minuteStep?: number;
/** Rows visible per wheel, odd. Default 5. */
visibleCount?: number;
/** Row height in px. Default 36. */
itemHeight?: number;
/** Tick sound per row crossed (WheelPicker's `sound`). Default false. */
sound?: boolean;
disabled?: boolean;
className?: string;
/** Prefix for each wheel's accessible name. Default "Time". */
"aria-label"?: string;
}
const DEFAULT_TIME: TimeValue = { hours: 9, minutes: 0 };
/**
* iOS-style time wheels: hour, minute and (for 12-hour) an AM/PM drum, each a
* `WheelPicker`, so drag/flick, mouse wheel and keyboard (↑/↓, Home/End on a
* focused wheel) all come from the shared drum. Controlled or uncontrolled;
* the value is always 24-hour `{ hours, minutes }`.
*/
export function TimePicker({
value: controlledValue,
defaultValue = DEFAULT_TIME,
onValueChange,
hourCycle = 12,
minuteStep = 5,
visibleCount = 5,
itemHeight = 36,
sound = false,
disabled = false,
className,
"aria-label": ariaLabel = "Time",
}: TimePickerProps) {
const [internal, setInternal] = useState<TimeValue>(defaultValue);
const controlled = controlledValue !== undefined;
const value = controlled ? controlledValue : internal;
const step = Math.max(1, Math.min(30, Math.floor(minuteStep)));
// Each wheel reports on its own, sometimes several times in one frame while
// dragging; composing from the latest committed time (not the render
// closure) keeps a quick hour turn from being undone by the minute wheel.
const latest = useRef(value);
useEffect(() => {
latest.current = value;
}, [value]);
const commit = (patch: Partial<TimeValue>) => {
const next = { ...latest.current, ...patch };
if (
next.hours === latest.current.hours &&
next.minutes === latest.current.minutes
)
return;
latest.current = next;
if (!controlled) setInternal(next);
onValueChange?.(next);
};
const hourOptions = useMemo(
() =>
hourCycle === 12
? // 12 sits first: 12 AM is hour 0, so the wheel reads 12, 1 … 11.
Array.from({ length: 12 }, (_, i) => ({
value: String(i),
label: String(i === 0 ? 12 : i),
}))
: Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: pad(i),
})),
[hourCycle],
);
const minuteOptions = useMemo(
() =>
Array.from({ length: Math.floor(59 / step) + 1 }, (_, i) => ({
value: String(i * step),
label: pad(i * step),
})),
[step],
);
const pm = value.hours >= 12;
const hourValue = String(hourCycle === 12 ? value.hours % 12 : value.hours);
const minuteValue = String(snapMinutes(value.minutes, step));
const hourLabel =
hourOptions.find((o) => o.value === hourValue)?.label ?? hourValue;
const wheel = "border-0 bg-transparent";
return (
<div
className={cn(
"inline-flex items-center gap-0.5 rounded-2xl border border-border bg-card p-1.5 text-base tabular-nums",
disabled && "opacity-50",
className,
)}
>
<WheelPicker
options={hourOptions}
value={hourValue}
onValueChange={(v) => {
const h = Number(v);
commit({
hours:
hourCycle === 12 ? h + (latest.current.hours >= 12 ? 12 : 0) : h,
});
}}
visibleCount={visibleCount}
itemHeight={itemHeight}
sound={sound}
disabled={disabled}
className={cn(wheel, "w-16")}
aria-label={`${ariaLabel}, hour ${hourLabel}`}
/>
<span aria-hidden className="-mx-1 font-medium text-muted-foreground">
:
</span>
<WheelPicker
options={minuteOptions}
value={minuteValue}
onValueChange={(v) => commit({ minutes: Number(v) })}
visibleCount={visibleCount}
itemHeight={itemHeight}
sound={sound}
disabled={disabled}
className={cn(wheel, "w-16")}
aria-label={`${ariaLabel}, minute ${pad(Number(minuteValue))}`}
/>
{hourCycle === 12 && (
<WheelPicker
options={PERIODS}
value={pm ? "PM" : "AM"}
onValueChange={(v) => {
const h = latest.current.hours % 12;
commit({ hours: v === "PM" ? h + 12 : h });
}}
visibleCount={visibleCount}
itemHeight={itemHeight}
sound={sound}
disabled={disabled}
className={cn(wheel, "w-16 text-sm")}
aria-label={`${ariaLabel}, ${pm ? "PM" : "AM"}`}
/>
)}
</div>
);
}
const PERIODS = ["AM", "PM"];
/**
* Moves focus to the first focusable node inside `container` once the morph
* popover has measured itself — it stays `visibility: hidden` for a frame or
* two, and focusing a hidden element silently fails.
*/
function useFocusFirst(
container: React.RefObject<HTMLElement | null>,
selector: string,
) {
useEffect(() => {
let frame = 0;
let tries = 0;
const attempt = () => {
const el = container.current?.querySelector<HTMLElement>(selector);
if (el) el.focus({ preventScroll: true });
if ((!el || document.activeElement !== el) && tries++ < 10)
frame = requestAnimationFrame(attempt);
};
frame = requestAnimationFrame(attempt);
return () => cancelAnimationFrame(frame);
}, [container, selector]);
}
export interface TimeFieldProps
extends Omit<TimePickerProps, "value" | "defaultValue" | "onValueChange" | "className"> {
/** Controlled committed time; null shows the placeholder. */
value?: TimeValue | null;
defaultValue?: TimeValue | null;
/** Fires once, when Done is pressed. Always 24-hour. */
onValueChange?: (time: TimeValue) => void;
/** Where the wheels start while the field is empty. Default 09:00. */
initialTime?: TimeValue;
placeholder?: string;
/** Default "en-US". */
locale?: string;
align?: "start" | "end";
className?: string;
}
/**
* Time field whose wheels morph open from the trigger corner. Turning the
* wheels edits a draft; Done commits it, Escape or an outside click discards
* it. Controlled or uncontrolled.
*/
export function TimeField({
value: controlledValue,
defaultValue = null,
onValueChange,
initialTime = DEFAULT_TIME,
placeholder = "Pick a time",
locale = "en-US",
hourCycle = 12,
align = "start",
disabled,
className,
"aria-label": ariaLabel = "Choose time",
...pickerProps
}: TimeFieldProps) {
const [open, setOpen] = useState(false);
const [internal, setInternal] = useState<TimeValue | null>(defaultValue);
const value = controlledValue !== undefined ? controlledValue : internal;
const [draft, setDraft] = useState<TimeValue>(value ?? initialTime);
const label = value ? formatTime(value, { locale, hourCycle }) : null;
const onOpenChange = (next: boolean) => {
if (next) setDraft(value ?? initialTime);
setOpen(next);
};
const done = () => {
if (controlledValue === undefined) setInternal(draft);
onValueChange?.(draft);
setOpen(false);
};
return (
<MorphPopover open={open} onOpenChange={onOpenChange}>
<MorphPopoverTrigger>
<button
type="button"
disabled={disabled}
aria-label={label ? `${ariaLabel}, ${label}` : ariaLabel}
className={cn(
"inline-flex h-10 min-w-40 items-center gap-2 rounded-xl border border-border bg-background px-3 text-left text-sm outline-none transition-colors hover:border-border-strong focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
label ? "text-foreground" : "text-muted-foreground",
className,
)}
>
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate tabular-nums">
{label ?? placeholder}
</span>
</button>
</MorphPopoverTrigger>
<MorphPopoverContent align={align} className="p-0">
<TimeFieldPanel
draft={draft}
setDraft={setDraft}
onDone={done}
hourCycle={hourCycle}
locale={locale}
{...pickerProps}
/>
</MorphPopoverContent>
</MorphPopover>
);
}
type TimeFieldPanelProps = Omit<
TimePickerProps,
"value" | "defaultValue" | "onValueChange" | "className" | "aria-label"
> & {
draft: TimeValue;
setDraft: (time: TimeValue) => void;
onDone: () => void;
locale: string;
};
function TimeFieldPanel({
draft,
setDraft,
onDone,
hourCycle,
locale,
...pickerProps
}: TimeFieldPanelProps) {
const ref = useRef<HTMLDivElement>(null);
useFocusFirst(ref, '[role="listbox"]');
return (
<div ref={ref} className="flex flex-col gap-2 p-2">
<TimePicker
{...pickerProps}
value={draft}
onValueChange={setDraft}
hourCycle={hourCycle}
className="border-0 bg-transparent p-0"
/>
<div className="flex items-center justify-between gap-3 border-border border-t px-1 pt-2">
<span className="text-sm font-medium tabular-nums">
{formatTime(draft, { locale, hourCycle })}
</span>
<button
type="button"
onClick={onDone}
className="inline-flex h-8 items-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground outline-none transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring"
>
Done
</button>
</div>
</div>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
/** The element the panel measures against — see `registerTrigger`. */
triggerRef: React.MutableRefObject<HTMLElement | null>;
registerTrigger: (node: HTMLElement | null) => void;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
// A trigger normally registers itself through MorphPopoverTrigger. It can't
// when something else already clones the element — a Tooltip wrapping the
// button, say — and an unregistered trigger leaves the panel with nothing to
// measure against, so it renders permanently invisible. The root boxes the
// trigger exactly (the content portals out of it), so it stands in until a
// real trigger registers, and stands in again if that one unmounts. Both are
// state, so a trigger arriving while the panel is open re-anchors it.
const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(
() => ({ current: trigger ?? root }),
[root, trigger],
);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: a dismissal hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded. When no trigger has
// registered, the root anchor stands in only if it can actually hold focus;
// there is nowhere better than where the keyboard already is, so leave it.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (!inPanel) return;
const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);
restore?.focus();
}, [root, setOpen, trigger]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
root &&
!root.contains(target) &&
!contentRef.current?.contains(target)
)
close();
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, root, close]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef: anchorRef,
registerTrigger: setTrigger,
contentRef,
}),
[open, setOpen, toggle, baseId, anchorRef],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={setRoot} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as React.MutableRefObject<T | null>).current = node;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergeRefs(childRef, ctx.registerTrigger),
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipHidden(side: Side, align: Align, radius: number) {
const top = side === "bottom" ? "0%" : "92%";
const bottom = side === "bottom" ? "92%" : "0%";
const right = align === "end" ? "0%" : "92%";
const left = align === "end" ? "92%" : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
const clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
className?: string;
}
export function MorphPopoverContent({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [portalReady, setPortalReady] = useState(false);
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
portalReady && ctx.open,
);
useEffect(() => setPortalReady(true), []);
const left = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
const top = layout
? side === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: layout.trigger.top - layout.content.height - sideOffset
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },
show: { opacity: 1, scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipHidden(side, align, radius),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipShown(radius),
transition: MORPH_CLIP_TRANSITION,
},
};
// Keep the server and first client render identical, then mount the portal.
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open ? (
<motion.div
data-morph-popover-portal=""
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "show"}
exit={reduce ? { opacity: 0 } : "hidden"}
transition={reduce ? { duration: 0.12 } : undefined}
style={{
left,
top,
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(side, align),
}}
className="fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]"
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role="dialog"
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
"use client";
import { useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type PointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { createTickPlayer } from "@/lib/tick-sound";
import {
capturePointer,
releasePointer,
TOUCH_GESTURE_CLASS,
} from "@/lib/touch";
import { cn } from "@/lib/utils";
export type WheelPickerOption = string | { label: string; value: string };
export interface WheelPickerProps {
options: WheelPickerOption[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Rows visible through the window, odd. More = flatter curve. Default 5. */
visibleCount?: number;
/** Row height in px. Default 36. */
itemHeight?: number;
disabled?: boolean;
/** Play a short tick each time the selected value changes. Default false. */
sound?: boolean;
className?: string;
"aria-label"?: string;
}
const DEG = Math.PI / 180;
// Physics constants, tuned for an iOS-like flick rather than reused from the
// shared spring tokens: the wheel coasts in whole-row units and springs to an
// integer detent, which a layout spring can't express cleanly.
const DECELERATION = 0.00042; // rows per ms², how fast a flick bleeds off (lower = freer)
const MAX_VELOCITY = 0.18; // rows per ms, caps a hard fling
const VELOCITY_WINDOW = 90; // ms of recent drag to average a release velocity over
const WHEEL_SENS = 0.012; // rows per pixel of wheel delta
const WHEEL_SETTLE = 110; // ms of wheel idle before snapping to a row
const easeOutCubic = (p: number) => 1 - (1 - p) ** 3;
// Overshoots the target by a few percent then settles — the little spring
// bounce as a row snaps home. `BACK` sets how far it drifts past.
const BACK = 1.35;
const easeOutBack = (p: number) =>
1 + (BACK + 1) * (p - 1) ** 3 + BACK * (p - 1) ** 2;
const clamp = (v: number, lo: number, hi: number) =>
Math.max(lo, Math.min(v, hi));
function optionValue(option: WheelPickerOption) {
return typeof option === "string" ? option : option.value;
}
function optionLabel(option: WheelPickerOption) {
return typeof option === "string" ? option : option.label;
}
export function WheelPicker({
options,
value,
defaultValue,
onValueChange,
visibleCount = 5,
itemHeight = 36,
disabled = false,
sound = false,
className,
"aria-label": ariaLabel,
}: WheelPickerProps) {
const reduce = useReducedMotion() ?? false;
const controlled = value !== undefined;
const last = options.length - 1;
const indexOf = useCallback(
(v: string | undefined) => {
const i = options.findIndex((o) => optionValue(o) === v);
return i < 0 ? 0 : i;
},
[options],
);
const [internal, setInternal] = useState(() => defaultValue ?? value);
const currentValue = controlled ? value : internal;
const [grabbing, setGrabbing] = useState(false);
// Cylinder geometry. Each row spans `itemAngle`; `radius` seats the rows on
// the drum; rows past `hideBeyond` sit behind the horizon and are dropped.
const { itemAngle, radius, height, hideBeyond } = useMemo(() => {
const rowsEachSide = Math.max(1, Math.floor(visibleCount / 2));
const cutoff = rowsEachSide + 1;
const angle = 90 / cutoff;
const r = itemHeight / Math.tan(angle * DEG);
return {
itemAngle: angle,
radius: r,
hideBeyond: cutoff,
height: Math.round(
2 * r * Math.sin(rowsEachSide * angle * DEG) + itemHeight,
),
};
}, [visibleCount, itemHeight]);
const container = useRef<HTMLDivElement>(null);
const drumRef = useRef<HTMLUListElement>(null);
const bandRef = useRef<HTMLUListElement>(null);
// Scroll position measured in rows (a float index). One source of truth for
// both layers: the drum rotates by `itemAngle·scroll`, the crisp band slides
// by `itemHeight·scroll`.
const scroll = useRef(indexOf(currentValue));
const raf = useRef(0);
const emitted = useRef(currentValue);
const tickPlayer = useRef<ReturnType<typeof createTickPlayer> | null>(null);
const lastTick = useRef(indexOf(currentValue));
const paint = useCallback(
(s: number) => {
const drum = drumRef.current;
const band = bandRef.current;
if (drum) {
drum.style.transform = `translateZ(${-radius}px) rotateX(${itemAngle * s}deg)`;
for (const node of Array.from(drum.children)) {
const li = node as HTMLLIElement;
const i = Number(li.dataset.index);
const want = Math.abs(i - s) > hideBeyond ? "hidden" : "visible";
// Write only on change — an unconditional write every frame thrashes
// style recalc and is what made the drag feel draggy on mobile.
if (li.style.visibility !== want) li.style.visibility = want;
}
}
// The band is the SAME drum, clipped to the centre row — driven by the
// identical transform so the crisp copy sits exactly on the dimmed one,
// with no parallax ghost as rows cross the window. It needs the same
// horizon cull, or the row on the back of the drum bleeds into the front.
if (band) {
band.style.transform = `translateZ(${-radius}px) rotateX(${itemAngle * s}deg)`;
for (const node of Array.from(band.children)) {
const li = node as HTMLLIElement;
const i = Number(li.dataset.index);
const want = Math.abs(i - s) > hideBeyond ? "hidden" : "visible";
if (li.style.visibility !== want) li.style.visibility = want;
}
}
},
[radius, itemAngle, hideBeyond],
);
const getPlayer = useCallback(() => {
if (!tickPlayer.current) tickPlayer.current = createTickPlayer();
return tickPlayer.current;
}, []);
const emit = useCallback(
(i: number) => {
const v = optionValue(options[clamp(i, 0, last)]);
if (v === emitted.current) return;
emitted.current = v;
// Reduced motion has no drum/glide path to tick from — it's an
// onClick straight into emit, so the tick lives here instead.
if (sound && reduce) getPlayer().play();
if (!controlled) setInternal(v);
onValueChange?.(v);
},
[options, last, controlled, onValueChange, sound, reduce, getPlayer],
);
// Drum path: fires a tick whenever the nearest row changes, independent of
// `emit` (which only fires on settle during a glide/fling, not per row
// crossed). Gated on `!reduce` since the reduced render never calls this.
const maybeTick = useCallback(
(pos: number) => {
const row = clamp(Math.round(pos), 0, last);
if (!sound || reduce) {
lastTick.current = row;
return;
}
if (row === lastTick.current) return;
lastTick.current = row;
getPlayer().play();
},
[sound, reduce, last, getPlayer],
);
const stop = useCallback(() => cancelAnimationFrame(raf.current), []);
// Ease `scroll` from where it is to an integer detent over `duration`. The
// easing may overshoot (spring bounce) before it resolves exactly on `to`.
const glide = useCallback(
(
to: number,
duration: number,
ease: (p: number) => number = easeOutCubic,
) => {
stop();
const from = scroll.current;
const dist = to - from;
if (!dist || duration <= 0) {
scroll.current = to;
paint(to);
maybeTick(to);
emit(to);
return;
}
const start = performance.now();
const tick = (now: number) => {
const p = (now - start) / duration;
if (p >= 1) {
scroll.current = to;
paint(to);
maybeTick(to);
emit(to);
return;
}
scroll.current = from + dist * ease(p);
paint(scroll.current);
maybeTick(scroll.current);
raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
},
[stop, paint, emit, maybeTick],
);
// Project where a flick of `velocity` (rows/ms) coasts to, snap to a row.
const fling = useCallback(
(velocity: number) => {
const from = scroll.current;
if (from < 0 || from > last) {
glide(clamp(Math.round(from), 0, last), 260); // rubber-band back
return;
}
const dir = Math.sign(velocity);
const coast = ((velocity * velocity) / (2 * DECELERATION)) * dir;
const to = clamp(Math.round(from + coast), 0, last);
// Long, spring-tipped settle so a hard flick reads as free momentum
// coasting to rest with a gentle bounce, never a clipped stop.
const duration = clamp(
Math.sqrt(Math.abs(to - from)) * 300 + 240,
280,
1700,
);
glide(to, duration, easeOutBack);
},
[glide, last],
);
const step = useCallback(
(by: number) =>
glide(clamp(Math.round(scroll.current) + by, 0, last), 300, easeOutBack),
[glide, last],
);
// Drag: track recent points for a release velocity; rubber-band past the ends.
const drag = useRef<{
y: number;
scroll: number;
pts: [number, number][];
} | null>(null);
// Coalesce touch/pointer moves to one paint per animation frame — raw move
// events fire several times per frame (and off-frame) on high-refresh
// screens, and painting each one is what made the drag feel choppy.
const dragFrame = useRef(0);
const latestY = useRef(0);
// Shared drag core, driven by a Y coordinate from either a mouse pointer or a
// native touch. Touch is bound with non-passive listeners in the effect below
// so the move can preventDefault the page scroll — React's synthetic touch
// events are passive and can't, which is why finger-drag did nothing on
// mobile.
const beginDrag = useCallback(
(y: number) => {
stop();
if (sound) getPlayer().prepare();
setGrabbing(true);
drag.current = {
y,
scroll: scroll.current,
pts: [[y, performance.now()]],
};
},
[stop, sound, getPlayer],
);
const moveDrag = useCallback(
(y: number) => {
const d = drag.current;
if (!d) return;
// Record every sample for an accurate release velocity, but only render
// the newest position once per frame.
latestY.current = y;
d.pts.push([y, performance.now()]);
if (d.pts.length > 8) d.pts.shift();
if (dragFrame.current) return;
dragFrame.current = requestAnimationFrame(() => {
dragFrame.current = 0;
const dd = drag.current;
if (!dd) return;
let next = dd.scroll + (dd.y - latestY.current) / itemHeight;
if (next < 0) next *= 0.3;
else if (next > last) next = last + (next - last) * 0.3;
scroll.current = next;
paint(next);
maybeTick(next);
emit(Math.round(clamp(next, 0, last)));
});
},
[itemHeight, last, paint, emit, maybeTick],
);
const endDrag = useCallback(() => {
const d = drag.current;
if (!d) return;
if (dragFrame.current) {
cancelAnimationFrame(dragFrame.current);
dragFrame.current = 0;
}
drag.current = null;
setGrabbing(false);
// Average velocity over the last `VELOCITY_WINDOW` ms of movement rather
// than the final two samples — a single noisy frame otherwise makes an
// even flick feel like it caught or slipped.
const pts = d.pts;
let v = 0;
if (pts.length > 1) {
const latest = pts[pts.length - 1];
let ref = pts[0];
for (const p of pts) {
if (latest[1] - p[1] <= VELOCITY_WINDOW) {
ref = p;
break;
}
}
const dt = latest[1] - ref[1];
if (dt > 0) {
const raw = (ref[0] - latest[0]) / itemHeight / dt;
v = clamp(raw, -MAX_VELOCITY, MAX_VELOCITY);
}
}
fling(v);
}, [itemHeight, fling]);
// Mouse / pen only — touch runs through the native listeners in the effect.
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled || reduce || event.pointerType === "touch") return;
beginDrag(event.clientY);
capturePointer(event.currentTarget, event.pointerId);
},
[disabled, reduce, beginDrag],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "touch") return;
moveDrag(event.clientY);
},
[moveDrag],
);
const onPointerUp = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "touch") return;
// Also the pointercancel handler, where the capture is already gone —
// an unguarded release throws there and the drum never settles.
releasePointer(event.currentTarget, event.pointerId);
endDrag();
},
[endDrag],
);
// Wheel drives `scroll` continuously — like a drag — then snaps once it goes
// idle. Firing a fresh eased step per notch instead stacks overlapping
// animations that keep interrupting each other, which read as lag.
const wheelSnap = useRef<ReturnType<typeof setTimeout> | null>(null);
const onWheel = useCallback(
(event: globalThis.WheelEvent) => {
// Native (non-passive) so preventDefault actually stops the page from
// scrolling behind the wheel — React's synthetic wheel listener is
// passive, so a handler on the element cannot cancel the scroll.
if (disabled || reduce) return;
event.preventDefault();
if (sound) getPlayer().prepare();
stop();
const px = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
const next = clamp(scroll.current + px * WHEEL_SENS, 0, last);
scroll.current = next;
paint(next);
maybeTick(next);
emit(Math.round(next));
if (wheelSnap.current) clearTimeout(wheelSnap.current);
wheelSnap.current = setTimeout(() => {
glide(clamp(Math.round(scroll.current), 0, last), 240, easeOutBack);
}, WHEEL_SETTLE);
},
[
disabled,
reduce,
sound,
last,
paint,
emit,
stop,
glide,
maybeTick,
getPlayer,
],
);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const at = Math.round(scroll.current);
const map: Record<string, number> = {
ArrowUp: -1,
ArrowDown: 1,
Home: -at,
End: last - at,
};
if (event.key in map) {
event.preventDefault();
if (sound) getPlayer().prepare();
step(map[event.key]);
}
},
[disabled, sound, last, step, getPlayer],
);
// Paint the starting frame, and follow controlled/value changes from outside
// unless a gesture is mid-flight.
useEffect(() => {
if (drag.current) return;
const target = indexOf(currentValue);
emitted.current = currentValue;
if (Math.abs(Math.round(scroll.current) - target) < 0.001) {
paint(scroll.current);
return;
}
glide(target, 260);
}, [currentValue, indexOf, paint, glide]);
useEffect(
() => () => {
cancelAnimationFrame(raf.current);
cancelAnimationFrame(dragFrame.current);
if (wheelSnap.current) clearTimeout(wheelSnap.current);
tickPlayer.current?.dispose();
},
[],
);
// Native touch + wheel listeners, bound non-passively so touchmove and wheel
// can block the page from scrolling while the wheel spins. React's synthetic
// touch/wheel handlers are passive, so preventDefault there is a no-op and the
// gesture scrolls the page instead of driving the drum.
useEffect(() => {
const el = container.current;
if (!el || reduce || disabled) return;
const onStart = (e: TouchEvent) => {
const t = e.touches[0];
if (t) beginDrag(t.clientY);
};
const onMove = (e: TouchEvent) => {
const t = e.touches[0];
if (!t || !drag.current) return;
e.preventDefault();
moveDrag(t.clientY);
};
const onEnd = () => endDrag();
el.addEventListener("touchstart", onStart, { passive: true });
el.addEventListener("touchmove", onMove, { passive: false });
el.addEventListener("touchend", onEnd);
el.addEventListener("touchcancel", onEnd);
el.addEventListener("wheel", onWheel, { passive: false });
return () => {
el.removeEventListener("touchstart", onStart);
el.removeEventListener("touchmove", onMove);
el.removeEventListener("touchend", onEnd);
el.removeEventListener("touchcancel", onEnd);
el.removeEventListener("wheel", onWheel);
};
}, [reduce, disabled, beginDrag, moveDrag, endDrag, onWheel]);
const maskFade =
"[mask-image:linear-gradient(to_bottom,transparent,#000_22%,#000_78%,transparent)]";
if (reduce) {
const pad = (height - itemHeight) / 2;
return (
<div
className={cn(
"relative overflow-hidden rounded-2xl border border-border bg-card",
disabled && "pointer-events-none opacity-50",
className,
)}
style={{ height }}
>
<div
className="pointer-events-none absolute inset-x-0 top-1/2 z-10 -translate-y-1/2 border-border border-y bg-foreground/[0.04]"
style={{ height: itemHeight }}
/>
<ul
className={cn(
"h-full snap-y snap-mandatory overflow-y-auto scroll-smooth [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
maskFade,
)}
style={{ paddingTop: pad, paddingBottom: pad }}
>
{options.map((option) => {
const v = optionValue(option);
return (
<li key={v} className="snap-center">
<button
type="button"
disabled={disabled}
onClick={() => emit(options.indexOf(option))}
className={cn(
"flex w-full items-center justify-center font-medium",
v === currentValue
? "text-foreground"
: "text-muted-foreground",
)}
style={{ height: itemHeight }}
>
{optionLabel(option)}
</button>
</li>
);
})}
</ul>
</div>
);
}
return (
<div
ref={container}
role="listbox"
aria-label={ariaLabel}
tabIndex={disabled ? -1 : 0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={cn(
"relative touch-none overflow-hidden rounded-2xl border border-border bg-card outline-none focus-visible:ring-2 focus-visible:ring-foreground/20",
// The drum owns the whole gesture; iOS must not open its callout or
// start a drag out of the same press and cancel ours.
TOUCH_GESTURE_CLASS,
grabbing ? "cursor-grabbing" : "cursor-grab",
disabled && "pointer-events-none opacity-50",
maskFade,
className,
)}
style={{ height, perspective: 1000 }}
>
{/* Curved drum of dimmed rows. */}
<ul
ref={drumRef}
aria-hidden
className="absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [backface-visibility:hidden] [transform-style:preserve-3d] [will-change:transform]"
>
{options.map((option, i) => (
<li
key={optionValue(option)}
data-index={i}
className="absolute inset-x-0 flex items-center justify-center font-medium text-muted-foreground"
style={{
top: -itemHeight / 2,
height: itemHeight,
transform: `rotateX(${-itemAngle * i}deg) translateZ(${radius}px)`,
}}
>
{optionLabel(option)}
</li>
))}
</ul>
{/* Center band: the very same drum, clipped to one row and drawn crisp.
Its own perspective, centred on the container middle, matches the main
drum's projection so the two copies register exactly. */}
<div
className="pointer-events-none absolute inset-x-0 top-1/2 z-10 -translate-y-1/2 overflow-hidden rounded-md bg-foreground/[0.04]"
style={{ height: itemHeight, perspective: 1000 }}
>
<ul
ref={bandRef}
aria-hidden
className="absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [backface-visibility:hidden] [transform-style:preserve-3d] [will-change:transform]"
>
{options.map((option, i) => (
<li
key={optionValue(option)}
data-index={i}
className="absolute inset-x-0 flex items-center justify-center font-medium text-foreground"
style={{
top: -itemHeight / 2,
height: itemHeight,
transform: `rotateX(${-itemAngle * i}deg) translateZ(${radius}px)`,
}}
>
{optionLabel(option)}
</li>
))}
</ul>
</div>
</div>
);
}
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
API Reference
toTimeString
hoursnumber—minutesnumber—TimePicker
value?TimeValueControlled time (24-hour).
—defaultValue?TimeValueUncontrolled initial time. Default 09:00.
{ hours: 9, minutes: 0 }onValueChange?((time: TimeValue) => void)Fires as each wheel settles on a row. Always 24-hour.
—hourCycle?12 | 2412 shows hour 12–11 plus an AM/PM wheel; 24 shows 00–23. Default 12.
12minuteStep?numberMinute wheel granularity. A value off the grid displays snapped to the nearest step and the first wheel turn commits the snapped minute. Default 5.
5visibleCount?numberRows visible per wheel, odd. Default 5.
5itemHeight?numberRow height in px. Default 36.
36sound?booleanTick sound per row crossed (WheelPicker's `sound`). Default false.
falsedisabled?booleanfalseclassName?string—aria-label?stringPrefix for each wheel's accessible name. Default "Time".
TimeTimeField
value?TimeValue | nullControlled committed time; null shows the placeholder.
—defaultValue?TimeValue | nullnullonValueChange?((time: TimeValue) => void)Fires once, when Done is pressed. Always 24-hour.
—initialTime?TimeValueWhere the wheels start while the field is empty. Default 09:00.
{ hours: 9, minutes: 0 }placeholder?stringPick a timelocale?stringDefault "en-US".
en-USalign?"end" | "start"startclassName?string—disabled?boolean—aria-label?stringPrefix for each wheel's accessible name. Default "Time".
Choose timehourCycle?12 | 2412 shows hour 12–11 plus an AM/PM wheel; 24 shows 00–23. Default 12.
12minuteStep?numberMinute wheel granularity. A value off the grid displays snapped to the nearest step and the first wheel turn commits the snapped minute. Default 5.
—visibleCount?numberRows visible per wheel, odd. Default 5.
—itemHeight?numberRow height in px. Default 36.
—sound?booleanTick sound per row crossed (WheelPicker's `sound`). Default false.
—Date & Time Picker
date-time-picker.tsxOne field whose panel morphs open with the calendar beside time wheels; edits a draft and commits on Done, labelled like "Oct 14, 2026, 9:30 AM".
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Synthesizes a short, dry mechanical detent for gesture feedback (e.g. the
// WheelPicker row-cross click) entirely at runtime. Several inharmonic modes
// make it read as a tiny physical impact instead of an oscillator beep, while
// the high-passed transient gives it definition through phone speakers.
const DURATION = 0.016; // seconds, short enough to stay distinct during a fling
const MASTER_GAIN = 0.055;
const MODES = [
{ frequency: 980, decay: 0.0038, gain: 0.5, phase: 0 },
{ frequency: 1820, decay: 0.0024, gain: 0.3, phase: 0.65 },
{ frequency: 3160, decay: 0.00115, gain: 0.12, phase: 1.3 },
] as const;
type AudioContextCtor = typeof AudioContext;
function getAudioContextCtor(): AudioContextCtor | undefined {
if (typeof window === "undefined") return undefined;
return (
window.AudioContext ??
(window as unknown as { webkitAudioContext?: AudioContextCtor })
.webkitAudioContext
);
}
// One shared context/buffer/gain for the whole page, ref-counted across every
// `createTickPlayer()` caller. Components like WheelPicker commonly compose
// several side by side (a date made of month/day/year drums) — giving each
// its own AudioContext would waste resources and risk hitting a browser's cap
// on concurrent contexts. The context only closes once every consumer that
// created a player has called `dispose()`.
let ctx: AudioContext | null = null;
let masterGain: GainNode | null = null;
let clickBuffer: AudioBuffer | null = null;
let consumers = 0;
function buildClickBuffer(context: AudioContext): AudioBuffer {
const sampleRate = context.sampleRate;
const length = Math.ceil(DURATION * sampleRate);
const buffer = context.createBuffer(1, length, sampleRate);
const data = buffer.getChannelData(0);
let peak = 0;
let previousNoise = 0;
for (let i = 0; i < length; i++) {
const t = i / sampleRate;
const attack = Math.min(1, t / 0.00012);
const fadeOut = Math.min(1, (DURATION - t) / 0.001);
let resonance = 0;
for (const mode of MODES) {
resonance +=
Math.sin(2 * Math.PI * mode.frequency * t + mode.phase) *
Math.exp(-t / mode.decay) *
mode.gain;
}
// Differencing consecutive noise samples removes the low, papery part of
// white noise and leaves only a tiny impact at the front of the sound.
const noise = Math.random() * 2 - 1;
const transient =
(noise - previousNoise) * Math.exp(-t / 0.00038) * 0.035;
previousNoise = noise;
data[i] = (resonance + transient) * attack * fadeOut;
peak = Math.max(peak, Math.abs(data[i]));
}
if (peak > 0) {
const norm = 0.9 / peak;
for (let i = 0; i < length; i++) data[i] *= norm;
}
return buffer;
}
function ensureContext() {
if (ctx) return;
const Ctor = getAudioContextCtor();
if (!Ctor) return;
ctx = new Ctor();
masterGain = ctx.createGain();
masterGain.gain.value = MASTER_GAIN;
masterGain.connect(ctx.destination);
clickBuffer = buildClickBuffer(ctx);
}
function prepareContext() {
ensureContext();
if (ctx?.state === "suspended") {
// Browsers may reject this when it is not called from a trusted gesture.
// Pointer, touch, wheel and keyboard entry points call `prepare()` first;
// this repeat attempt keeps direct `play()` calls safe as well.
void ctx.resume().catch(() => undefined);
}
}
interface TickPlayer {
prepare: () => void;
play: () => void;
dispose: () => void;
}
/**
* Lazily-initialized tick player. No `AudioContext` is created until the first
* `prepare()` or `play()` call anywhere on the page. Every player shares the
* same context/buffer, ref-counted so `dispose()` only tears it down once the
* last consumer is gone. Safe during SSR or in browsers without Web Audio:
* `prepare()` and `play()` become silent no-ops.
*/
export function createTickPlayer(): TickPlayer {
consumers++;
let disposed = false;
let activeSource: AudioBufferSourceNode | null = null;
return {
prepare() {
if (disposed) return;
prepareContext();
},
play() {
if (disposed) return;
prepareContext();
if (!ctx || !masterGain || !clickBuffer) return;
// A fast fling can cross rows more quickly than the tail of a tick.
// Cutting that tail keeps the feedback dry instead of stacking into a
// louder metallic ring.
activeSource?.stop();
const source = ctx.createBufferSource();
source.buffer = clickBuffer;
source.connect(masterGain);
source.onended = () => {
source.disconnect();
if (activeSource === source) activeSource = null;
};
activeSource = source;
source.start();
},
dispose() {
if (disposed) return;
disposed = true;
activeSource?.stop();
activeSource = null;
consumers = Math.max(0, consumers - 1);
if (consumers === 0 && ctx) {
ctx.close();
ctx = null;
masterGain = null;
clickBuffer = null;
}
},
};
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/date-picker
import { CalendarClock } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Calendar } from "@/components/motion/calendar";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import {
type HourCycle,
TimePicker,
type TimeValue,
} from "@/components/motion/time-picker";
import { cn } from "@/lib/utils";
const MEDIUM_DATE_SHORT_TIME: Intl.DateTimeFormatOptions = {
dateStyle: "medium",
timeStyle: "short",
};
export interface DateTimePickerProps {
/** Controlled committed date and time; null shows the placeholder. */
value?: Date | null;
defaultValue?: Date | null;
/** Fires once, when Done is pressed. */
onValueChange?: (date: Date) => void;
placeholder?: string;
/** Intl options for the trigger label. Default `{ dateStyle: "medium",
* timeStyle: "short" }` → "Oct 14, 2026, 9:30 AM" in en-US. */
formatOptions?: Intl.DateTimeFormatOptions;
/** Default "en-US". */
locale?: string;
/** 12 adds an AM/PM wheel; 24 shows 00–23. Also sets the label's clock. Default 12. */
hourCycle?: HourCycle;
/** Minute wheel granularity. Default 5. */
minuteStep?: number;
/** Where the wheels start while the field is empty. Default 09:00. */
initialTime?: TimeValue;
/** Earliest moment. Days before it are disabled, and a committed value
* earlier on that same day is clamped up to it. */
minDate?: Date;
/** Latest moment, clamped the same way. */
maxDate?: Date;
isDateDisabled?: (date: Date) => boolean;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
align?: "start" | "end";
disabled?: boolean;
className?: string;
"aria-label"?: string;
}
type Draft = { date: Date | null; time: TimeValue };
const combine = (date: Date, time: TimeValue) =>
new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
time.hours,
time.minutes,
);
/**
* One field for a date and a time. Its panel morphs open from the trigger with
* the calendar on the left and time wheels on the right (stacked below `sm`).
* Picking a day and turning the wheels edit a draft; Done commits it, Escape
* or an outside click discards it. Controlled or uncontrolled.
*/
export function DateTimePicker({
value: controlledValue,
defaultValue = null,
onValueChange,
placeholder = "Pick a date and time",
formatOptions = MEDIUM_DATE_SHORT_TIME,
locale = "en-US",
hourCycle = 12,
minuteStep = 5,
initialTime = { hours: 9, minutes: 0 },
minDate,
maxDate,
isDateDisabled,
weekStartsOn,
align = "start",
disabled,
className,
"aria-label": ariaLabel = "Choose date and time",
}: DateTimePickerProps) {
const [open, setOpen] = useState(false);
const [internal, setInternal] = useState<Date | null>(defaultValue);
const value = controlledValue !== undefined ? controlledValue : internal;
const [draft, setDraft] = useState<Draft>({ date: null, time: initialTime });
const label = useMemo(
() =>
value
? new Intl.DateTimeFormat(locale, {
...formatOptions,
hourCycle: hourCycle === 12 ? "h12" : "h23",
}).format(value)
: null,
[value, locale, formatOptions, hourCycle],
);
const onOpenChange = (next: boolean) => {
if (next)
setDraft(
value
? {
date: value,
time: { hours: value.getHours(), minutes: value.getMinutes() },
}
: { date: null, time: initialTime },
);
setOpen(next);
};
const done = () => {
if (!draft.date) return;
let next = combine(draft.date, draft.time);
if (minDate && next < minDate) next = new Date(minDate);
if (maxDate && next > maxDate) next = new Date(maxDate);
if (controlledValue === undefined) setInternal(next);
onValueChange?.(next);
setOpen(false);
};
return (
<MorphPopover open={open} onOpenChange={onOpenChange}>
<MorphPopoverTrigger>
<button
type="button"
disabled={disabled}
aria-label={label ? `${ariaLabel}, ${label}` : ariaLabel}
className={cn(
"inline-flex h-10 min-w-56 items-center gap-2 rounded-xl border border-border bg-background px-3 text-left text-sm outline-none transition-colors hover:border-border-strong focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
label ? "text-foreground" : "text-muted-foreground",
className,
)}
>
<CalendarClock className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate tabular-nums">
{label ?? placeholder}
</span>
</button>
</MorphPopoverTrigger>
<MorphPopoverContent align={align} className="p-0">
<DateTimePanel
draft={draft}
setDraft={setDraft}
onDone={done}
locale={locale}
hourCycle={hourCycle}
minuteStep={minuteStep}
minDate={minDate}
maxDate={maxDate}
isDateDisabled={isDateDisabled}
weekStartsOn={weekStartsOn}
/>
</MorphPopoverContent>
</MorphPopover>
);
}
type DateTimePanelProps = {
draft: Draft;
setDraft: (update: (prev: Draft) => Draft) => void;
onDone: () => void;
locale: string;
hourCycle: HourCycle;
minuteStep: number;
minDate?: Date;
maxDate?: Date;
isDateDisabled?: (date: Date) => boolean;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
};
function DateTimePanel({
draft,
setDraft,
onDone,
locale,
hourCycle,
minuteStep,
minDate,
maxDate,
isDateDisabled,
weekStartsOn,
}: DateTimePanelProps) {
const ref = useRef<HTMLDivElement>(null);
// Land keyboard focus on the calendar's roving day once the morph panel has
// measured itself (it is `visibility: hidden` for the first frame or two).
useEffect(() => {
let frame = 0;
let tries = 0;
const attempt = () => {
const el = ref.current?.querySelector<HTMLElement>(
'button[data-date][tabindex="0"]',
);
if (el) el.focus({ preventScroll: true });
if ((!el || document.activeElement !== el) && tries++ < 10)
frame = requestAnimationFrame(attempt);
};
frame = requestAnimationFrame(attempt);
return () => cancelAnimationFrame(frame);
}, []);
const summary = draft.date
? new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
hourCycle: hourCycle === 12 ? "h12" : "h23",
}).format(combine(draft.date, draft.time))
: "Pick a day";
return (
<div ref={ref} className="flex flex-col">
<div className="flex flex-col sm:flex-row">
<Calendar
value={draft.date}
onValueChange={(date) => setDraft((prev) => ({ ...prev, date }))}
locale={locale}
minDate={minDate}
maxDate={maxDate}
isDateDisabled={isDateDisabled}
weekStartsOn={weekStartsOn}
className="border-0 bg-transparent"
/>
<div className="flex flex-col items-center justify-center gap-2 border-border border-t p-3 sm:border-t-0 sm:border-l">
<span className="text-xs font-medium text-muted-foreground">
Time
</span>
<TimePicker
value={draft.time}
onValueChange={(time) => setDraft((prev) => ({ ...prev, time }))}
hourCycle={hourCycle}
minuteStep={minuteStep}
className="bg-muted/40"
/>
</div>
</div>
<div className="flex items-center justify-between gap-3 border-border border-t px-3 py-2">
<span
className={cn(
"truncate text-sm tabular-nums",
draft.date ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
{summary}
</span>
<button
type="button"
onClick={onDone}
disabled={!draft.date}
className="inline-flex h-8 shrink-0 items-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground outline-none transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
>
Done
</button>
</div>
</div>
);
}
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type DateRange = { from: Date; to?: Date };
/** A whole week row: `from` is its first day, `to` its seventh. */
export type WeekRange = { from: Date; to: Date };
/** Per-day extras from `getDayMeta`. */
export type CalendarDayMeta = {
/** Short text under the day number (a price, a count). Cells grow taller
* when any day has meta; the label is also read after the date. */
label?: string;
/** "accent" tints the label (deals, highlights); "muted" fades it. */
tone?: "muted" | "accent";
/** Blocks the day and strikes it through, like `isDateDisabled`. */
disabled?: boolean;
};
type CalendarBaseProps = {
/** Earliest selectable day (inclusive). */
minDate?: Date;
/** Latest selectable day (inclusive). */
maxDate?: Date;
/** Extra per-day rule, e.g. weekends or booked dates. */
isDateDisabled?: (date: Date) => boolean;
/** 0 = Sunday … 6 = Saturday. Default 0. */
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** BCP 47 locale for month, weekday and day labels. Default "en-US" — a
* fixed default keeps server and client output identical for hydration. */
locale?: string;
/** Controlled visible month (any day inside it). */
month?: Date;
/** Uncontrolled initial visible month. Default: the selection or today. */
defaultMonth?: Date;
onMonthChange?: (month: Date) => void;
/** Months shown side by side (stacked below `sm`). Default 1. With more
* than one, days outside each month are left blank so none repeats. */
numberOfMonths?: number;
/** Adds an ISO 8601 week-number column ("Wk") before each row. */
showWeekNumber?: boolean;
/** Per-day label, tone and blocking, e.g. nightly prices or booked days.
* Passing it switches the grid to taller cells that fit the label. */
getDayMeta?: (date: Date) => CalendarDayMeta | null | undefined;
className?: string;
"aria-label"?: string;
};
export type CalendarSingleProps = CalendarBaseProps & {
mode?: "single";
value?: Date | null;
defaultValue?: Date | null;
onValueChange?: (date: Date) => void;
};
export type CalendarRangeProps = CalendarBaseProps & {
mode: "range";
value?: DateRange | null;
defaultValue?: DateRange | null;
onValueChange?: (range: DateRange) => void;
/** Fewest nights (days between start and end) a finished range may span.
* While the end is being chosen, days too close are blocked. */
minNights?: number;
/** Most nights a finished range may span; farther days are blocked. */
maxNights?: number;
};
export type CalendarWeekProps = CalendarBaseProps & {
mode: "week";
value?: WeekRange | null;
defaultValue?: WeekRange | null;
/** Called with the picked day's whole row, honouring `weekStartsOn`. */
onValueChange?: (week: WeekRange) => void;
};
export type CalendarMultipleProps = CalendarBaseProps & {
mode: "multiple";
value?: Date[] | null;
defaultValue?: Date[] | null;
/** Called with the selection sorted by date. */
onValueChange?: (dates: Date[]) => void;
/** Most days that can be picked; further picks are ignored. */
max?: number;
};
export type CalendarProps =
| CalendarSingleProps
| CalendarRangeProps
| CalendarWeekProps
| CalendarMultipleProps;
// ── date helpers (local time, day precision) ──────────────────────────────
const startOfDay = (d: Date) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate());
const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
const addDays = (d: Date, n: number) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
/** Adds months, clamping the day so Jan 31 + 1 month lands on Feb 28/29. */
function addMonths(d: Date, n: number) {
const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
const lastDay = new Date(
target.getFullYear(),
target.getMonth() + 1,
0,
).getDate();
target.setDate(Math.min(d.getDate(), lastDay));
return target;
}
const sameDay = (a?: Date | null, b?: Date | null) =>
!!a &&
!!b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
const sameMonth = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
const dayKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const monthKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
/** Whole days from `a` to `b`; rounding absorbs DST hour shifts. */
const daysBetween = (a: Date, b: Date) =>
Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / 86_400_000);
/** ISO 8601 week number: weeks start Monday, week 1 holds Jan 4. */
function isoWeek(d: Date) {
const thursday = addDays(d, 3 - ((d.getDay() + 6) % 7));
const jan4 = new Date(thursday.getFullYear(), 0, 4);
const firstThursday = addDays(jan4, 3 - ((jan4.getDay() + 6) % 7));
return 1 + Math.round(daysBetween(firstThursday, thursday) / 7);
}
/** Always 6 weeks, so the panel never changes height between months. */
function buildWeeks(month: Date, weekStartsOn: number) {
const first = startOfMonth(month);
const offset = (first.getDay() - weekStartsOn + 7) % 7;
const start = addDays(first, -offset);
return Array.from({ length: 6 }, (_, w) =>
Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),
);
}
// Month swap is a short directional slide; the exit is quicker than the entry.
const MONTH_ENTER = { duration: 0.26, ease: EASE_OUT } as const;
const MONTH_EXIT = { duration: 0.16, ease: EASE_OUT } as const;
/**
* Month-grid calendar for single dates, ranges, whole weeks or several
* separate days, one or more months wide. The selected day is a pill that
* glides between days on a shared-layout spring; a picked week is one pill
* across its row; months slide in from the direction you travel. Optional ISO
* week numbers, per-day labels (`getDayMeta`) and night limits for ranges.
* Full keyboard grid navigation (arrows, Home/End, PageUp/PageDown,
* Shift+Page for years). Controlled or uncontrolled for both the selection
* and the visible month; reduced-motion safe.
*/
export function Calendar(props: CalendarProps) {
const {
minDate,
maxDate,
isDateDisabled,
weekStartsOn = 0,
locale = "en-US",
month: controlledMonth,
defaultMonth,
onMonthChange,
numberOfMonths = 1,
showWeekNumber = false,
getDayMeta,
className,
"aria-label": ariaLabel,
} = props;
const isRange = props.mode === "range";
const isWeek = props.mode === "week";
const isMultiple = props.mode === "multiple";
const count = Math.max(1, Math.floor(numberOfMonths));
const minNights = isRange ? ((props as CalendarRangeProps).minNights ?? 0) : 0;
const maxNights = isRange ? (props as CalendarRangeProps).maxNights : undefined;
// Taller cells only when there is something to put under the number.
const tall = !!getDayMeta;
const uid = useId();
const reduce = useReducedMotion();
// ── selection ──
const [internalValue, setInternalValue] = useState<
Date | DateRange | WeekRange | Date[] | null
>(props.defaultValue ?? null);
const valueControlled = props.value !== undefined;
const value = valueControlled ? (props.value ?? null) : internalValue;
const single =
isRange || isWeek || isMultiple ? null : (value as Date | null);
const range = isRange ? (value as DateRange | null) : null;
const week = isWeek ? (value as WeekRange | null) : null;
const picked = isMultiple ? ((value as Date[] | null) ?? []) : [];
const isPicked = (d: Date) => picked.some((p) => sameDay(p, d));
// ── visible months ──
const anchor = single ?? range?.from ?? week?.from ?? picked[0] ?? null;
const [internalMonth, setInternalMonth] = useState(() =>
startOfMonth(defaultMonth ?? anchor ?? new Date()),
);
const month = controlledMonth
? startOfMonth(controlledMonth)
: internalMonth;
const [direction, setDirection] = useState(1);
const lastMonth = addMonths(month, count - 1);
const inWindow = (d: Date) => {
const m = startOfMonth(d);
return m >= month && m <= lastMonth;
};
const setMonth = useCallback(
(next: Date) => {
const m = startOfMonth(next);
if (sameMonth(m, month)) return;
setDirection(m > month ? 1 : -1);
if (!controlledMonth) setInternalMonth(m);
onMonthChange?.(m);
},
[month, controlledMonth, onMonthChange],
);
// "Today" is read after mount: the server's clock and timezone can disagree
// with the viewer's, and a mismatched marker would break hydration.
const [today, setToday] = useState<Date | null>(null);
useEffect(() => setToday(startOfDay(new Date())), []);
// ── roving focus ──
const [focused, setFocused] = useState<Date>(() =>
startOfDay(anchor ?? defaultMonth ?? new Date()),
);
const gridRef = useRef<HTMLDivElement>(null);
const shouldFocus = useRef(false);
// Range hover preview: the band follows the pointer until the end is picked.
// In week mode it is the row under the pointer or keyboard focus.
const [hovered, setHovered] = useState<Date | null>(null);
// Keep the tab stop inside the visible months when they change by buttons.
const focusInMonth = inWindow(focused)
? focused
: anchor && inWindow(anchor)
? anchor
: month;
useEffect(() => {
if (!shouldFocus.current) return;
shouldFocus.current = false;
gridRef.current
?.querySelector<HTMLButtonElement>(
`[data-month="${monthKey(focusInMonth)}"] [data-date="${dayKey(focusInMonth)}"]`,
)
?.focus();
}, [focusInMonth]);
const isDisabled = useCallback(
(d: Date) =>
(minDate && d < startOfDay(minDate)) ||
(maxDate && d > startOfDay(maxDate)) ||
!!isDateDisabled?.(d) ||
!!getDayMeta?.(d)?.disabled,
[minDate, maxDate, isDateDisabled, getDayMeta],
);
// While a range end is being chosen, days that would make it too short or
// too long are blocked (earlier days stay open: they restart the range).
const choosingEnd = !!range && !range.to;
const nightsAllowed = (n: number) =>
n >= minNights && (maxNights === undefined || n <= maxNights);
const blockedByNights = (d: Date) =>
choosingEnd &&
!!range &&
d > range.from &&
!nightsAllowed(daysBetween(range.from, d));
const weekStart = (d: Date) =>
addDays(d, -((d.getDay() - weekStartsOn + 7) % 7));
const select = (d: Date) => {
if (isDisabled(d) || blockedByNights(d)) return;
if (!inWindow(d)) setMonth(d);
setFocused(d);
if (isWeek) {
const from = weekStart(d);
const next: WeekRange = { from, to: addDays(from, 6) };
if (!valueControlled) setInternalValue(next);
(props as CalendarWeekProps).onValueChange?.(next);
return;
}
if (isMultiple) {
const max = (props as CalendarMultipleProps).max;
const next = isPicked(d)
? picked.filter((p) => !sameDay(p, d))
: max !== undefined && picked.length >= max
? null
: [...picked, d].sort((a, b) => a.getTime() - b.getTime());
if (!next) return;
if (!valueControlled) setInternalValue(next);
(props as CalendarMultipleProps).onValueChange?.(next);
return;
}
if (!isRange) {
if (!valueControlled) setInternalValue(d);
(props as CalendarSingleProps).onValueChange?.(d);
return;
}
// First click starts a range; a later day closes it; an earlier day, the
// start again when it would be too short, or a click on a finished range
// starts over.
const next: DateRange =
range &&
!range.to &&
d >= range.from &&
nightsAllowed(daysBetween(range.from, d))
? { from: range.from, to: d }
: { from: d };
if (!valueControlled) setInternalValue(next);
(props as CalendarRangeProps).onValueChange?.(next);
};
const onGridKeyDown = (e: KeyboardEvent) => {
const base = focusInMonth;
let next: Date | null = null;
switch (e.key) {
case "ArrowLeft":
next = addDays(base, -1);
break;
case "ArrowRight":
next = addDays(base, 1);
break;
case "ArrowUp":
next = addDays(base, -7);
break;
case "ArrowDown":
next = addDays(base, 7);
break;
case "Home":
next = addDays(base, -((base.getDay() - weekStartsOn + 7) % 7));
break;
case "End":
next = addDays(base, 6 - ((base.getDay() - weekStartsOn + 7) % 7));
break;
case "PageUp":
next = addMonths(base, e.shiftKey ? -12 : -1);
break;
case "PageDown":
next = addMonths(base, e.shiftKey ? 12 : 1);
break;
default:
return;
}
e.preventDefault();
shouldFocus.current = true;
setFocused(next);
// Scroll the window only when focus leaves it, by as little as needed.
if (!inWindow(next))
setMonth(next < month ? next : addMonths(startOfMonth(next), 1 - count));
};
// ── labels ──
const monthFormat = useMemo(
() => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
[locale],
);
const weekdays = useMemo(() => {
const short = new Intl.DateTimeFormat(locale, { weekday: "short" });
const long = new Intl.DateTimeFormat(locale, { weekday: "long" });
// 2023-01-01 was a Sunday.
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7));
return { short: short.format(d).slice(0, 2), long: long.format(d) };
});
}, [locale, weekStartsOn]);
const dayLabel = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
}),
[locale],
);
const prevDisabled =
!!minDate && startOfMonth(minDate) >= month;
const nextDisabled =
!!maxDate && startOfMonth(maxDate) <= lastMonth;
// Range band end: the picked end, or the hovered day while choosing one.
// A day that can't close the range doesn't stretch the band to it.
const bandEnd =
range &&
!range.to &&
hovered &&
hovered > range.from &&
!isDisabled(hovered) &&
!blockedByNights(hovered)
? hovered
: range?.to;
// Week mode: the row being previewed (pointer or keyboard focus).
const previewWeek = isWeek && hovered ? weekStart(hovered) : null;
const navButton =
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40";
const months = Array.from({ length: count }, (_, i) => addMonths(month, i));
// Weekday index of the column, so connecting bands stop at the row ends.
const column = (d: Date) => (d.getDay() - weekStartsOn + 7) % 7;
const cellSize = tall ? "h-12 w-10" : "size-9";
return (
<div
className={cn(
"w-fit rounded-2xl border border-border bg-card p-3 text-foreground",
className,
)}
>
<div
ref={gridRef}
onPointerLeave={() => setHovered(null)}
className="flex flex-col gap-x-6 gap-y-4 sm:flex-row"
>
{months.map((m, i) => {
const mk = monthKey(m);
const weeks = buildWeeks(m, weekStartsOn);
const labelId = `${uid}-label-${i}`;
return (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: columns are positional; each keeps its own month transition.
key={i}
className="min-w-0"
>
<div className="mb-2 flex items-center justify-between gap-2 px-1">
{i === 0 ? (
<button
type="button"
aria-label="Previous month"
disabled={prevDisabled}
onClick={() => setMonth(addMonths(month, -1))}
className={navButton}
>
<ChevronLeft className="h-4 w-4" />
</button>
) : (
<span aria-hidden className="size-8" />
)}
<h2
id={labelId}
aria-live={i === 0 ? "polite" : undefined}
className="text-sm font-medium tabular-nums"
>
{monthFormat.format(m)}
</h2>
{i === count - 1 ? (
<button
type="button"
aria-label="Next month"
disabled={nextDisabled}
onClick={() => setMonth(addMonths(month, 1))}
className={navButton}
>
<ChevronRight className="h-4 w-4" />
</button>
) : (
<span aria-hidden className="size-8" />
)}
</div>
<div className="relative overflow-hidden">
<AnimatePresence
initial={false}
mode="popLayout"
custom={direction}
>
<motion.table
key={mk}
data-month={mk}
custom={direction}
aria-labelledby={ariaLabel ? undefined : labelId}
aria-label={ariaLabel ? `${ariaLabel}, ${monthFormat.format(m)}` : undefined}
onKeyDown={onGridKeyDown}
variants={{
enter: (dir: number) => ({
opacity: 0,
x: reduce ? 0 : dir * 28,
filter: reduce ? "blur(0px)" : "blur(4px)",
}),
center: {
opacity: 1,
x: 0,
filter: "blur(0px)",
transition: MONTH_ENTER,
},
exit: (dir: number) => ({
opacity: 0,
x: reduce ? 0 : dir * -28,
filter: reduce ? "blur(0px)" : "blur(4px)",
transition: MONTH_EXIT,
}),
}}
initial="enter"
animate="center"
exit="exit"
className="border-collapse"
>
<thead>
<tr>
{showWeekNumber && (
<th
scope="col"
abbr="Week"
className="h-8 w-8 text-[11px] font-medium text-muted-foreground/70"
>
Wk
</th>
)}
{weekdays.map((w) => (
<th
key={w.long}
scope="col"
abbr={w.long}
className={cn(
"h-8 text-[11px] font-medium text-muted-foreground",
tall ? "w-10" : "w-9",
)}
>
{w.short}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((row) => {
// Days this row actually draws: side-by-side months
// leave the other month's days blank.
const shown =
count > 1 ? row.filter((d) => sameMonth(d, m)) : row;
const weekSelected =
!!week && sameDay(row[0], weekStart(week.from));
const weekPreview =
!weekSelected && sameDay(row[0], previewWeek);
// ISO weeks run Monday–Sunday; the row's Thursday
// decides which one a differently started row is.
const thursday =
row.find((d) => d.getDay() === 4) ?? row[0];
return (
<tr key={dayKey(row[0])}>
{showWeekNumber && (
<th
scope="row"
className={cn(
"p-0 text-center text-[11px] font-normal tabular-nums transition-colors",
weekSelected
? "font-medium text-foreground"
: "text-muted-foreground/70",
)}
>
{shown.length > 0 ? isoWeek(thursday) : null}
</th>
)}
{row.map((d) => {
const outside = !sameMonth(d, m);
// Side-by-side months would show these days twice.
if (outside && count > 1)
return (
<td key={dayKey(d)} className="p-0">
<span className={cn("block", cellSize)} />
</td>
);
const meta = getDayMeta?.(d) ?? null;
const disabled = isDisabled(d);
const nightsBlocked =
!disabled && blockedByNights(d);
const isToday = sameDay(d, today);
const isFrom = sameDay(d, range?.from);
const isTo = sameDay(d, bandEnd);
const inBand =
!!range?.from &&
!!bandEnd &&
d > range.from &&
d < bandEnd;
const chosen = isMultiple && isPicked(d);
const inWeek =
!!week &&
d >= startOfDay(week.from) &&
d <= startOfDay(week.to);
const selected = isRange
? isFrom || (isTo && !!range?.to)
: isWeek
? inWeek
: isMultiple
? chosen
: sameDay(d, single);
const endpoint = isFrom || isTo;
// Picked neighbours in the same row merge into one
// pill, so a run of days reads as a block.
const joinPrev =
chosen &&
column(d) > 0 &&
sameMonth(addDays(d, -1), m) &&
isPicked(addDays(d, -1));
const joinNext =
chosen &&
column(d) < 6 &&
sameMonth(addDays(d, 1), m) &&
isPicked(addDays(d, 1));
// The week pill lives in the row's first drawn
// cell and spans every drawn day after it.
const weekPill =
isWeek &&
(weekSelected || weekPreview) &&
sameDay(d, shown[0]);
return (
<td key={dayKey(d)} className="relative p-0">
{(inBand ||
(endpoint &&
bandEnd &&
!sameDay(range?.from, bandEnd))) && (
<span
aria-hidden
className={cn(
"absolute inset-y-0.5 bg-primary/10",
inBand && "inset-x-0",
isFrom && "left-1/2 right-0",
isTo && "left-0 right-1/2",
)}
/>
)}
{weekPill && (
<motion.span
aria-hidden
// Selected and previewed rows each glide
// between weeks, scoped per month.
layoutId={`${uid}-${mk}-${weekSelected ? "week" : "week-preview"}`}
transition={
reduce ? { duration: 0 } : SPRING_LAYOUT
}
style={{ width: `${shown.length * 100}%` }}
className={cn(
"pointer-events-none absolute inset-y-0 left-0 rounded-lg",
weekSelected ? "bg-primary" : "bg-muted",
)}
/>
)}
<button
type="button"
data-date={dayKey(d)}
tabIndex={
sameDay(d, focusInMonth) && !outside
? 0
: -1
}
aria-label={
meta?.label
? `${dayLabel.format(d)}, ${meta.label}`
: dayLabel.format(d)
}
aria-pressed={selected}
aria-disabled={
disabled || nightsBlocked || undefined
}
aria-current={isToday ? "date" : undefined}
title={
nightsBlocked
? daysBetween(range?.from ?? d, d) <
minNights
? `Minimum ${minNights} nights`
: `Maximum ${maxNights} nights`
: undefined
}
onClick={() => select(d)}
onFocus={() => {
setFocused(d);
if (isWeek) setHovered(d);
}}
onBlur={() => isWeek && setHovered(null)}
onPointerEnter={() =>
(isRange || isWeek) && setHovered(d)
}
className={cn(
"relative isolate flex items-center justify-center rounded-lg text-sm tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
cellSize,
tall && "flex-col gap-0.5",
outside
? "text-muted-foreground/50"
: "text-foreground",
!selected &&
!disabled &&
!nightsBlocked &&
!isWeek &&
"hover:bg-muted",
selected && "text-primary-foreground",
selected &&
outside &&
"text-primary-foreground/60",
isTo && !range?.to && "text-foreground",
nightsBlocked &&
"cursor-not-allowed text-muted-foreground/40",
disabled &&
"cursor-not-allowed text-muted-foreground/40 line-through",
)}
>
{selected &&
!isWeek &&
(isRange || isMultiple ? (
<motion.span
aria-hidden
initial={
reduce ? false : { scale: 0.7 }
}
animate={{ scale: 1 }}
transition={SPRING_PRESS}
className={cn(
"absolute inset-0 -z-10 rounded-lg bg-primary",
joinPrev && "rounded-l-none",
joinNext && "rounded-r-none",
)}
/>
) : (
<motion.span
aria-hidden
// Scoped per month so the pill glides
// between days but never across the
// sliding grids.
layoutId={`${uid}-${mk}-selected`}
transition={
reduce
? { duration: 0 }
: SPRING_LAYOUT
}
className="absolute inset-0 -z-10 rounded-lg bg-primary"
/>
))}
{isTo && !range?.to && (
<span
aria-hidden
className="absolute inset-0 -z-10 rounded-lg border border-primary/40"
/>
)}
{tall ? (
<span className="leading-none">
{d.getDate()}
</span>
) : (
d.getDate()
)}
{tall && (
<span
aria-hidden
className={cn(
"h-2.5 text-[10px] leading-none",
meta?.tone === "accent"
? // Tinted toward the foreground so
// the green keeps contrast on
// light and dark cards.
"font-medium text-[color:color-mix(in_oklab,var(--chart-3)_70%,var(--foreground))]"
: meta?.tone === "muted"
? "text-muted-foreground/60"
: "text-muted-foreground",
selected &&
"text-primary-foreground/75",
(disabled || nightsBlocked) &&
"text-muted-foreground/40",
)}
>
{meta?.label}
</span>
)}
{isToday && (
<span
aria-hidden
className={cn(
"absolute size-1 rounded-full",
tall ? "top-1.5 right-1.5" : "bottom-1",
selected
? "bg-primary-foreground"
: "bg-primary",
)}
/>
)}
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</motion.table>
</AnimatePresence>
</div>
</div>
);
})}
</div>
</div>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
/** The element the panel measures against — see `registerTrigger`. */
triggerRef: React.MutableRefObject<HTMLElement | null>;
registerTrigger: (node: HTMLElement | null) => void;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
// A trigger normally registers itself through MorphPopoverTrigger. It can't
// when something else already clones the element — a Tooltip wrapping the
// button, say — and an unregistered trigger leaves the panel with nothing to
// measure against, so it renders permanently invisible. The root boxes the
// trigger exactly (the content portals out of it), so it stands in until a
// real trigger registers, and stands in again if that one unmounts. Both are
// state, so a trigger arriving while the panel is open re-anchors it.
const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(
() => ({ current: trigger ?? root }),
[root, trigger],
);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: a dismissal hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded. When no trigger has
// registered, the root anchor stands in only if it can actually hold focus;
// there is nowhere better than where the keyboard already is, so leave it.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (!inPanel) return;
const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);
restore?.focus();
}, [root, setOpen, trigger]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
root &&
!root.contains(target) &&
!contentRef.current?.contains(target)
)
close();
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, root, close]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef: anchorRef,
registerTrigger: setTrigger,
contentRef,
}),
[open, setOpen, toggle, baseId, anchorRef],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={setRoot} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as React.MutableRefObject<T | null>).current = node;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergeRefs(childRef, ctx.registerTrigger),
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipHidden(side: Side, align: Align, radius: number) {
const top = side === "bottom" ? "0%" : "92%";
const bottom = side === "bottom" ? "92%" : "0%";
const right = align === "end" ? "0%" : "92%";
const left = align === "end" ? "92%" : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
const clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
className?: string;
}
export function MorphPopoverContent({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [portalReady, setPortalReady] = useState(false);
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
portalReady && ctx.open,
);
useEffect(() => setPortalReady(true), []);
const left = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
const top = layout
? side === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: layout.trigger.top - layout.content.height - sideOffset
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },
show: { opacity: 1, scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipHidden(side, align, radius),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipShown(radius),
transition: MORPH_CLIP_TRANSITION,
},
};
// Keep the server and first client render identical, then mount the portal.
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open ? (
<motion.div
data-morph-popover-portal=""
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "show"}
exit={reduce ? { opacity: 0 } : "hidden"}
transition={reduce ? { duration: 0.12 } : undefined}
style={{
left,
top,
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(side, align),
}}
className="fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]"
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role="dialog"
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
"use client";
import { Clock } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { WheelPicker } from "@/components/motion/wheel-picker";
import { cn } from "@/lib/utils";
/**
* A wall-clock time, always 24-hour internally: `hours` 0–23, `minutes` 0–59.
* The 12/24-hour choice is display only, so a value never changes meaning when
* `hourCycle` does. Use `parseTime` / `toTimeString` for "HH:mm" strings.
*/
export type TimeValue = { hours: number; minutes: number };
export type HourCycle = 12 | 24;
const pad = (n: number) => String(n).padStart(2, "0");
/** "09:30" → { hours: 9, minutes: 30 }. Returns null for anything malformed. */
export function parseTime(input: string): TimeValue | null {
const match = /^(\d{1,2}):(\d{2})$/.exec(input.trim());
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59) return null;
return { hours, minutes };
}
/** { hours: 9, minutes: 30 } → "09:30" (24-hour, zero padded). */
export function toTimeString(time: TimeValue) {
return `${pad(time.hours)}:${pad(time.minutes)}`;
}
/** Locale label for a time, e.g. "9:30 AM" (en-US, 12h) or "09:30" (24h). */
export function formatTime(
time: TimeValue,
{ locale = "en-US", hourCycle = 12 }: { locale?: string; hourCycle?: HourCycle } = {},
) {
// A fixed calendar day: only the clock fields are read, and a constant date
// keeps the output identical on server and client.
return new Intl.DateTimeFormat(locale, {
hour: "numeric",
minute: "2-digit",
hourCycle: hourCycle === 12 ? "h12" : "h23",
}).format(new Date(2000, 0, 1, time.hours, time.minutes));
}
/** Snaps minutes onto the step grid the minute wheel offers. */
function snapMinutes(minutes: number, step: number) {
const last = Math.floor(59 / step) * step;
return Math.min(Math.round(minutes / step) * step, last);
}
export interface TimePickerProps {
/** Controlled time (24-hour). */
value?: TimeValue;
/** Uncontrolled initial time. Default 09:00. */
defaultValue?: TimeValue;
/** Fires as each wheel settles on a row. Always 24-hour. */
onValueChange?: (time: TimeValue) => void;
/** 12 shows hour 12–11 plus an AM/PM wheel; 24 shows 00–23. Default 12. */
hourCycle?: HourCycle;
/** Minute wheel granularity. A value off the grid displays snapped to the
* nearest step and the first wheel turn commits the snapped minute. Default 5. */
minuteStep?: number;
/** Rows visible per wheel, odd. Default 5. */
visibleCount?: number;
/** Row height in px. Default 36. */
itemHeight?: number;
/** Tick sound per row crossed (WheelPicker's `sound`). Default false. */
sound?: boolean;
disabled?: boolean;
className?: string;
/** Prefix for each wheel's accessible name. Default "Time". */
"aria-label"?: string;
}
const DEFAULT_TIME: TimeValue = { hours: 9, minutes: 0 };
/**
* iOS-style time wheels: hour, minute and (for 12-hour) an AM/PM drum, each a
* `WheelPicker`, so drag/flick, mouse wheel and keyboard (↑/↓, Home/End on a
* focused wheel) all come from the shared drum. Controlled or uncontrolled;
* the value is always 24-hour `{ hours, minutes }`.
*/
export function TimePicker({
value: controlledValue,
defaultValue = DEFAULT_TIME,
onValueChange,
hourCycle = 12,
minuteStep = 5,
visibleCount = 5,
itemHeight = 36,
sound = false,
disabled = false,
className,
"aria-label": ariaLabel = "Time",
}: TimePickerProps) {
const [internal, setInternal] = useState<TimeValue>(defaultValue);
const controlled = controlledValue !== undefined;
const value = controlled ? controlledValue : internal;
const step = Math.max(1, Math.min(30, Math.floor(minuteStep)));
// Each wheel reports on its own, sometimes several times in one frame while
// dragging; composing from the latest committed time (not the render
// closure) keeps a quick hour turn from being undone by the minute wheel.
const latest = useRef(value);
useEffect(() => {
latest.current = value;
}, [value]);
const commit = (patch: Partial<TimeValue>) => {
const next = { ...latest.current, ...patch };
if (
next.hours === latest.current.hours &&
next.minutes === latest.current.minutes
)
return;
latest.current = next;
if (!controlled) setInternal(next);
onValueChange?.(next);
};
const hourOptions = useMemo(
() =>
hourCycle === 12
? // 12 sits first: 12 AM is hour 0, so the wheel reads 12, 1 … 11.
Array.from({ length: 12 }, (_, i) => ({
value: String(i),
label: String(i === 0 ? 12 : i),
}))
: Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: pad(i),
})),
[hourCycle],
);
const minuteOptions = useMemo(
() =>
Array.from({ length: Math.floor(59 / step) + 1 }, (_, i) => ({
value: String(i * step),
label: pad(i * step),
})),
[step],
);
const pm = value.hours >= 12;
const hourValue = String(hourCycle === 12 ? value.hours % 12 : value.hours);
const minuteValue = String(snapMinutes(value.minutes, step));
const hourLabel =
hourOptions.find((o) => o.value === hourValue)?.label ?? hourValue;
const wheel = "border-0 bg-transparent";
return (
<div
className={cn(
"inline-flex items-center gap-0.5 rounded-2xl border border-border bg-card p-1.5 text-base tabular-nums",
disabled && "opacity-50",
className,
)}
>
<WheelPicker
options={hourOptions}
value={hourValue}
onValueChange={(v) => {
const h = Number(v);
commit({
hours:
hourCycle === 12 ? h + (latest.current.hours >= 12 ? 12 : 0) : h,
});
}}
visibleCount={visibleCount}
itemHeight={itemHeight}
sound={sound}
disabled={disabled}
className={cn(wheel, "w-16")}
aria-label={`${ariaLabel}, hour ${hourLabel}`}
/>
<span aria-hidden className="-mx-1 font-medium text-muted-foreground">
:
</span>
<WheelPicker
options={minuteOptions}
value={minuteValue}
onValueChange={(v) => commit({ minutes: Number(v) })}
visibleCount={visibleCount}
itemHeight={itemHeight}
sound={sound}
disabled={disabled}
className={cn(wheel, "w-16")}
aria-label={`${ariaLabel}, minute ${pad(Number(minuteValue))}`}
/>
{hourCycle === 12 && (
<WheelPicker
options={PERIODS}
value={pm ? "PM" : "AM"}
onValueChange={(v) => {
const h = latest.current.hours % 12;
commit({ hours: v === "PM" ? h + 12 : h });
}}
visibleCount={visibleCount}
itemHeight={itemHeight}
sound={sound}
disabled={disabled}
className={cn(wheel, "w-16 text-sm")}
aria-label={`${ariaLabel}, ${pm ? "PM" : "AM"}`}
/>
)}
</div>
);
}
const PERIODS = ["AM", "PM"];
/**
* Moves focus to the first focusable node inside `container` once the morph
* popover has measured itself — it stays `visibility: hidden` for a frame or
* two, and focusing a hidden element silently fails.
*/
function useFocusFirst(
container: React.RefObject<HTMLElement | null>,
selector: string,
) {
useEffect(() => {
let frame = 0;
let tries = 0;
const attempt = () => {
const el = container.current?.querySelector<HTMLElement>(selector);
if (el) el.focus({ preventScroll: true });
if ((!el || document.activeElement !== el) && tries++ < 10)
frame = requestAnimationFrame(attempt);
};
frame = requestAnimationFrame(attempt);
return () => cancelAnimationFrame(frame);
}, [container, selector]);
}
export interface TimeFieldProps
extends Omit<TimePickerProps, "value" | "defaultValue" | "onValueChange" | "className"> {
/** Controlled committed time; null shows the placeholder. */
value?: TimeValue | null;
defaultValue?: TimeValue | null;
/** Fires once, when Done is pressed. Always 24-hour. */
onValueChange?: (time: TimeValue) => void;
/** Where the wheels start while the field is empty. Default 09:00. */
initialTime?: TimeValue;
placeholder?: string;
/** Default "en-US". */
locale?: string;
align?: "start" | "end";
className?: string;
}
/**
* Time field whose wheels morph open from the trigger corner. Turning the
* wheels edits a draft; Done commits it, Escape or an outside click discards
* it. Controlled or uncontrolled.
*/
export function TimeField({
value: controlledValue,
defaultValue = null,
onValueChange,
initialTime = DEFAULT_TIME,
placeholder = "Pick a time",
locale = "en-US",
hourCycle = 12,
align = "start",
disabled,
className,
"aria-label": ariaLabel = "Choose time",
...pickerProps
}: TimeFieldProps) {
const [open, setOpen] = useState(false);
const [internal, setInternal] = useState<TimeValue | null>(defaultValue);
const value = controlledValue !== undefined ? controlledValue : internal;
const [draft, setDraft] = useState<TimeValue>(value ?? initialTime);
const label = value ? formatTime(value, { locale, hourCycle }) : null;
const onOpenChange = (next: boolean) => {
if (next) setDraft(value ?? initialTime);
setOpen(next);
};
const done = () => {
if (controlledValue === undefined) setInternal(draft);
onValueChange?.(draft);
setOpen(false);
};
return (
<MorphPopover open={open} onOpenChange={onOpenChange}>
<MorphPopoverTrigger>
<button
type="button"
disabled={disabled}
aria-label={label ? `${ariaLabel}, ${label}` : ariaLabel}
className={cn(
"inline-flex h-10 min-w-40 items-center gap-2 rounded-xl border border-border bg-background px-3 text-left text-sm outline-none transition-colors hover:border-border-strong focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
label ? "text-foreground" : "text-muted-foreground",
className,
)}
>
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate tabular-nums">
{label ?? placeholder}
</span>
</button>
</MorphPopoverTrigger>
<MorphPopoverContent align={align} className="p-0">
<TimeFieldPanel
draft={draft}
setDraft={setDraft}
onDone={done}
hourCycle={hourCycle}
locale={locale}
{...pickerProps}
/>
</MorphPopoverContent>
</MorphPopover>
);
}
type TimeFieldPanelProps = Omit<
TimePickerProps,
"value" | "defaultValue" | "onValueChange" | "className" | "aria-label"
> & {
draft: TimeValue;
setDraft: (time: TimeValue) => void;
onDone: () => void;
locale: string;
};
function TimeFieldPanel({
draft,
setDraft,
onDone,
hourCycle,
locale,
...pickerProps
}: TimeFieldPanelProps) {
const ref = useRef<HTMLDivElement>(null);
useFocusFirst(ref, '[role="listbox"]');
return (
<div ref={ref} className="flex flex-col gap-2 p-2">
<TimePicker
{...pickerProps}
value={draft}
onValueChange={setDraft}
hourCycle={hourCycle}
className="border-0 bg-transparent p-0"
/>
<div className="flex items-center justify-between gap-3 border-border border-t px-1 pt-2">
<span className="text-sm font-medium tabular-nums">
{formatTime(draft, { locale, hourCycle })}
</span>
<button
type="button"
onClick={onDone}
className="inline-flex h-8 items-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground outline-none transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring"
>
Done
</button>
</div>
</div>
);
}
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
"use client";
import { useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type PointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { createTickPlayer } from "@/lib/tick-sound";
import {
capturePointer,
releasePointer,
TOUCH_GESTURE_CLASS,
} from "@/lib/touch";
import { cn } from "@/lib/utils";
export type WheelPickerOption = string | { label: string; value: string };
export interface WheelPickerProps {
options: WheelPickerOption[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Rows visible through the window, odd. More = flatter curve. Default 5. */
visibleCount?: number;
/** Row height in px. Default 36. */
itemHeight?: number;
disabled?: boolean;
/** Play a short tick each time the selected value changes. Default false. */
sound?: boolean;
className?: string;
"aria-label"?: string;
}
const DEG = Math.PI / 180;
// Physics constants, tuned for an iOS-like flick rather than reused from the
// shared spring tokens: the wheel coasts in whole-row units and springs to an
// integer detent, which a layout spring can't express cleanly.
const DECELERATION = 0.00042; // rows per ms², how fast a flick bleeds off (lower = freer)
const MAX_VELOCITY = 0.18; // rows per ms, caps a hard fling
const VELOCITY_WINDOW = 90; // ms of recent drag to average a release velocity over
const WHEEL_SENS = 0.012; // rows per pixel of wheel delta
const WHEEL_SETTLE = 110; // ms of wheel idle before snapping to a row
const easeOutCubic = (p: number) => 1 - (1 - p) ** 3;
// Overshoots the target by a few percent then settles — the little spring
// bounce as a row snaps home. `BACK` sets how far it drifts past.
const BACK = 1.35;
const easeOutBack = (p: number) =>
1 + (BACK + 1) * (p - 1) ** 3 + BACK * (p - 1) ** 2;
const clamp = (v: number, lo: number, hi: number) =>
Math.max(lo, Math.min(v, hi));
function optionValue(option: WheelPickerOption) {
return typeof option === "string" ? option : option.value;
}
function optionLabel(option: WheelPickerOption) {
return typeof option === "string" ? option : option.label;
}
export function WheelPicker({
options,
value,
defaultValue,
onValueChange,
visibleCount = 5,
itemHeight = 36,
disabled = false,
sound = false,
className,
"aria-label": ariaLabel,
}: WheelPickerProps) {
const reduce = useReducedMotion() ?? false;
const controlled = value !== undefined;
const last = options.length - 1;
const indexOf = useCallback(
(v: string | undefined) => {
const i = options.findIndex((o) => optionValue(o) === v);
return i < 0 ? 0 : i;
},
[options],
);
const [internal, setInternal] = useState(() => defaultValue ?? value);
const currentValue = controlled ? value : internal;
const [grabbing, setGrabbing] = useState(false);
// Cylinder geometry. Each row spans `itemAngle`; `radius` seats the rows on
// the drum; rows past `hideBeyond` sit behind the horizon and are dropped.
const { itemAngle, radius, height, hideBeyond } = useMemo(() => {
const rowsEachSide = Math.max(1, Math.floor(visibleCount / 2));
const cutoff = rowsEachSide + 1;
const angle = 90 / cutoff;
const r = itemHeight / Math.tan(angle * DEG);
return {
itemAngle: angle,
radius: r,
hideBeyond: cutoff,
height: Math.round(
2 * r * Math.sin(rowsEachSide * angle * DEG) + itemHeight,
),
};
}, [visibleCount, itemHeight]);
const container = useRef<HTMLDivElement>(null);
const drumRef = useRef<HTMLUListElement>(null);
const bandRef = useRef<HTMLUListElement>(null);
// Scroll position measured in rows (a float index). One source of truth for
// both layers: the drum rotates by `itemAngle·scroll`, the crisp band slides
// by `itemHeight·scroll`.
const scroll = useRef(indexOf(currentValue));
const raf = useRef(0);
const emitted = useRef(currentValue);
const tickPlayer = useRef<ReturnType<typeof createTickPlayer> | null>(null);
const lastTick = useRef(indexOf(currentValue));
const paint = useCallback(
(s: number) => {
const drum = drumRef.current;
const band = bandRef.current;
if (drum) {
drum.style.transform = `translateZ(${-radius}px) rotateX(${itemAngle * s}deg)`;
for (const node of Array.from(drum.children)) {
const li = node as HTMLLIElement;
const i = Number(li.dataset.index);
const want = Math.abs(i - s) > hideBeyond ? "hidden" : "visible";
// Write only on change — an unconditional write every frame thrashes
// style recalc and is what made the drag feel draggy on mobile.
if (li.style.visibility !== want) li.style.visibility = want;
}
}
// The band is the SAME drum, clipped to the centre row — driven by the
// identical transform so the crisp copy sits exactly on the dimmed one,
// with no parallax ghost as rows cross the window. It needs the same
// horizon cull, or the row on the back of the drum bleeds into the front.
if (band) {
band.style.transform = `translateZ(${-radius}px) rotateX(${itemAngle * s}deg)`;
for (const node of Array.from(band.children)) {
const li = node as HTMLLIElement;
const i = Number(li.dataset.index);
const want = Math.abs(i - s) > hideBeyond ? "hidden" : "visible";
if (li.style.visibility !== want) li.style.visibility = want;
}
}
},
[radius, itemAngle, hideBeyond],
);
const getPlayer = useCallback(() => {
if (!tickPlayer.current) tickPlayer.current = createTickPlayer();
return tickPlayer.current;
}, []);
const emit = useCallback(
(i: number) => {
const v = optionValue(options[clamp(i, 0, last)]);
if (v === emitted.current) return;
emitted.current = v;
// Reduced motion has no drum/glide path to tick from — it's an
// onClick straight into emit, so the tick lives here instead.
if (sound && reduce) getPlayer().play();
if (!controlled) setInternal(v);
onValueChange?.(v);
},
[options, last, controlled, onValueChange, sound, reduce, getPlayer],
);
// Drum path: fires a tick whenever the nearest row changes, independent of
// `emit` (which only fires on settle during a glide/fling, not per row
// crossed). Gated on `!reduce` since the reduced render never calls this.
const maybeTick = useCallback(
(pos: number) => {
const row = clamp(Math.round(pos), 0, last);
if (!sound || reduce) {
lastTick.current = row;
return;
}
if (row === lastTick.current) return;
lastTick.current = row;
getPlayer().play();
},
[sound, reduce, last, getPlayer],
);
const stop = useCallback(() => cancelAnimationFrame(raf.current), []);
// Ease `scroll` from where it is to an integer detent over `duration`. The
// easing may overshoot (spring bounce) before it resolves exactly on `to`.
const glide = useCallback(
(
to: number,
duration: number,
ease: (p: number) => number = easeOutCubic,
) => {
stop();
const from = scroll.current;
const dist = to - from;
if (!dist || duration <= 0) {
scroll.current = to;
paint(to);
maybeTick(to);
emit(to);
return;
}
const start = performance.now();
const tick = (now: number) => {
const p = (now - start) / duration;
if (p >= 1) {
scroll.current = to;
paint(to);
maybeTick(to);
emit(to);
return;
}
scroll.current = from + dist * ease(p);
paint(scroll.current);
maybeTick(scroll.current);
raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
},
[stop, paint, emit, maybeTick],
);
// Project where a flick of `velocity` (rows/ms) coasts to, snap to a row.
const fling = useCallback(
(velocity: number) => {
const from = scroll.current;
if (from < 0 || from > last) {
glide(clamp(Math.round(from), 0, last), 260); // rubber-band back
return;
}
const dir = Math.sign(velocity);
const coast = ((velocity * velocity) / (2 * DECELERATION)) * dir;
const to = clamp(Math.round(from + coast), 0, last);
// Long, spring-tipped settle so a hard flick reads as free momentum
// coasting to rest with a gentle bounce, never a clipped stop.
const duration = clamp(
Math.sqrt(Math.abs(to - from)) * 300 + 240,
280,
1700,
);
glide(to, duration, easeOutBack);
},
[glide, last],
);
const step = useCallback(
(by: number) =>
glide(clamp(Math.round(scroll.current) + by, 0, last), 300, easeOutBack),
[glide, last],
);
// Drag: track recent points for a release velocity; rubber-band past the ends.
const drag = useRef<{
y: number;
scroll: number;
pts: [number, number][];
} | null>(null);
// Coalesce touch/pointer moves to one paint per animation frame — raw move
// events fire several times per frame (and off-frame) on high-refresh
// screens, and painting each one is what made the drag feel choppy.
const dragFrame = useRef(0);
const latestY = useRef(0);
// Shared drag core, driven by a Y coordinate from either a mouse pointer or a
// native touch. Touch is bound with non-passive listeners in the effect below
// so the move can preventDefault the page scroll — React's synthetic touch
// events are passive and can't, which is why finger-drag did nothing on
// mobile.
const beginDrag = useCallback(
(y: number) => {
stop();
if (sound) getPlayer().prepare();
setGrabbing(true);
drag.current = {
y,
scroll: scroll.current,
pts: [[y, performance.now()]],
};
},
[stop, sound, getPlayer],
);
const moveDrag = useCallback(
(y: number) => {
const d = drag.current;
if (!d) return;
// Record every sample for an accurate release velocity, but only render
// the newest position once per frame.
latestY.current = y;
d.pts.push([y, performance.now()]);
if (d.pts.length > 8) d.pts.shift();
if (dragFrame.current) return;
dragFrame.current = requestAnimationFrame(() => {
dragFrame.current = 0;
const dd = drag.current;
if (!dd) return;
let next = dd.scroll + (dd.y - latestY.current) / itemHeight;
if (next < 0) next *= 0.3;
else if (next > last) next = last + (next - last) * 0.3;
scroll.current = next;
paint(next);
maybeTick(next);
emit(Math.round(clamp(next, 0, last)));
});
},
[itemHeight, last, paint, emit, maybeTick],
);
const endDrag = useCallback(() => {
const d = drag.current;
if (!d) return;
if (dragFrame.current) {
cancelAnimationFrame(dragFrame.current);
dragFrame.current = 0;
}
drag.current = null;
setGrabbing(false);
// Average velocity over the last `VELOCITY_WINDOW` ms of movement rather
// than the final two samples — a single noisy frame otherwise makes an
// even flick feel like it caught or slipped.
const pts = d.pts;
let v = 0;
if (pts.length > 1) {
const latest = pts[pts.length - 1];
let ref = pts[0];
for (const p of pts) {
if (latest[1] - p[1] <= VELOCITY_WINDOW) {
ref = p;
break;
}
}
const dt = latest[1] - ref[1];
if (dt > 0) {
const raw = (ref[0] - latest[0]) / itemHeight / dt;
v = clamp(raw, -MAX_VELOCITY, MAX_VELOCITY);
}
}
fling(v);
}, [itemHeight, fling]);
// Mouse / pen only — touch runs through the native listeners in the effect.
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled || reduce || event.pointerType === "touch") return;
beginDrag(event.clientY);
capturePointer(event.currentTarget, event.pointerId);
},
[disabled, reduce, beginDrag],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "touch") return;
moveDrag(event.clientY);
},
[moveDrag],
);
const onPointerUp = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "touch") return;
// Also the pointercancel handler, where the capture is already gone —
// an unguarded release throws there and the drum never settles.
releasePointer(event.currentTarget, event.pointerId);
endDrag();
},
[endDrag],
);
// Wheel drives `scroll` continuously — like a drag — then snaps once it goes
// idle. Firing a fresh eased step per notch instead stacks overlapping
// animations that keep interrupting each other, which read as lag.
const wheelSnap = useRef<ReturnType<typeof setTimeout> | null>(null);
const onWheel = useCallback(
(event: globalThis.WheelEvent) => {
// Native (non-passive) so preventDefault actually stops the page from
// scrolling behind the wheel — React's synthetic wheel listener is
// passive, so a handler on the element cannot cancel the scroll.
if (disabled || reduce) return;
event.preventDefault();
if (sound) getPlayer().prepare();
stop();
const px = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
const next = clamp(scroll.current + px * WHEEL_SENS, 0, last);
scroll.current = next;
paint(next);
maybeTick(next);
emit(Math.round(next));
if (wheelSnap.current) clearTimeout(wheelSnap.current);
wheelSnap.current = setTimeout(() => {
glide(clamp(Math.round(scroll.current), 0, last), 240, easeOutBack);
}, WHEEL_SETTLE);
},
[
disabled,
reduce,
sound,
last,
paint,
emit,
stop,
glide,
maybeTick,
getPlayer,
],
);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const at = Math.round(scroll.current);
const map: Record<string, number> = {
ArrowUp: -1,
ArrowDown: 1,
Home: -at,
End: last - at,
};
if (event.key in map) {
event.preventDefault();
if (sound) getPlayer().prepare();
step(map[event.key]);
}
},
[disabled, sound, last, step, getPlayer],
);
// Paint the starting frame, and follow controlled/value changes from outside
// unless a gesture is mid-flight.
useEffect(() => {
if (drag.current) return;
const target = indexOf(currentValue);
emitted.current = currentValue;
if (Math.abs(Math.round(scroll.current) - target) < 0.001) {
paint(scroll.current);
return;
}
glide(target, 260);
}, [currentValue, indexOf, paint, glide]);
useEffect(
() => () => {
cancelAnimationFrame(raf.current);
cancelAnimationFrame(dragFrame.current);
if (wheelSnap.current) clearTimeout(wheelSnap.current);
tickPlayer.current?.dispose();
},
[],
);
// Native touch + wheel listeners, bound non-passively so touchmove and wheel
// can block the page from scrolling while the wheel spins. React's synthetic
// touch/wheel handlers are passive, so preventDefault there is a no-op and the
// gesture scrolls the page instead of driving the drum.
useEffect(() => {
const el = container.current;
if (!el || reduce || disabled) return;
const onStart = (e: TouchEvent) => {
const t = e.touches[0];
if (t) beginDrag(t.clientY);
};
const onMove = (e: TouchEvent) => {
const t = e.touches[0];
if (!t || !drag.current) return;
e.preventDefault();
moveDrag(t.clientY);
};
const onEnd = () => endDrag();
el.addEventListener("touchstart", onStart, { passive: true });
el.addEventListener("touchmove", onMove, { passive: false });
el.addEventListener("touchend", onEnd);
el.addEventListener("touchcancel", onEnd);
el.addEventListener("wheel", onWheel, { passive: false });
return () => {
el.removeEventListener("touchstart", onStart);
el.removeEventListener("touchmove", onMove);
el.removeEventListener("touchend", onEnd);
el.removeEventListener("touchcancel", onEnd);
el.removeEventListener("wheel", onWheel);
};
}, [reduce, disabled, beginDrag, moveDrag, endDrag, onWheel]);
const maskFade =
"[mask-image:linear-gradient(to_bottom,transparent,#000_22%,#000_78%,transparent)]";
if (reduce) {
const pad = (height - itemHeight) / 2;
return (
<div
className={cn(
"relative overflow-hidden rounded-2xl border border-border bg-card",
disabled && "pointer-events-none opacity-50",
className,
)}
style={{ height }}
>
<div
className="pointer-events-none absolute inset-x-0 top-1/2 z-10 -translate-y-1/2 border-border border-y bg-foreground/[0.04]"
style={{ height: itemHeight }}
/>
<ul
className={cn(
"h-full snap-y snap-mandatory overflow-y-auto scroll-smooth [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
maskFade,
)}
style={{ paddingTop: pad, paddingBottom: pad }}
>
{options.map((option) => {
const v = optionValue(option);
return (
<li key={v} className="snap-center">
<button
type="button"
disabled={disabled}
onClick={() => emit(options.indexOf(option))}
className={cn(
"flex w-full items-center justify-center font-medium",
v === currentValue
? "text-foreground"
: "text-muted-foreground",
)}
style={{ height: itemHeight }}
>
{optionLabel(option)}
</button>
</li>
);
})}
</ul>
</div>
);
}
return (
<div
ref={container}
role="listbox"
aria-label={ariaLabel}
tabIndex={disabled ? -1 : 0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={cn(
"relative touch-none overflow-hidden rounded-2xl border border-border bg-card outline-none focus-visible:ring-2 focus-visible:ring-foreground/20",
// The drum owns the whole gesture; iOS must not open its callout or
// start a drag out of the same press and cancel ours.
TOUCH_GESTURE_CLASS,
grabbing ? "cursor-grabbing" : "cursor-grab",
disabled && "pointer-events-none opacity-50",
maskFade,
className,
)}
style={{ height, perspective: 1000 }}
>
{/* Curved drum of dimmed rows. */}
<ul
ref={drumRef}
aria-hidden
className="absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [backface-visibility:hidden] [transform-style:preserve-3d] [will-change:transform]"
>
{options.map((option, i) => (
<li
key={optionValue(option)}
data-index={i}
className="absolute inset-x-0 flex items-center justify-center font-medium text-muted-foreground"
style={{
top: -itemHeight / 2,
height: itemHeight,
transform: `rotateX(${-itemAngle * i}deg) translateZ(${radius}px)`,
}}
>
{optionLabel(option)}
</li>
))}
</ul>
{/* Center band: the very same drum, clipped to one row and drawn crisp.
Its own perspective, centred on the container middle, matches the main
drum's projection so the two copies register exactly. */}
<div
className="pointer-events-none absolute inset-x-0 top-1/2 z-10 -translate-y-1/2 overflow-hidden rounded-md bg-foreground/[0.04]"
style={{ height: itemHeight, perspective: 1000 }}
>
<ul
ref={bandRef}
aria-hidden
className="absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [backface-visibility:hidden] [transform-style:preserve-3d] [will-change:transform]"
>
{options.map((option, i) => (
<li
key={optionValue(option)}
data-index={i}
className="absolute inset-x-0 flex items-center justify-center font-medium text-foreground"
style={{
top: -itemHeight / 2,
height: itemHeight,
transform: `rotateX(${-itemAngle * i}deg) translateZ(${radius}px)`,
}}
>
{optionLabel(option)}
</li>
))}
</ul>
</div>
</div>
);
}
API Reference
value?Date | nullControlled committed date and time; null shows the placeholder.
—defaultValue?Date | nullnullonValueChange?((date: Date) => void)Fires once, when Done is pressed.
—placeholder?stringPick a date and timeformatOptions?DateTimeFormatOptionsIntl options for the trigger label. Default `{ dateStyle: "medium", timeStyle: "short" }` → "Oct 14, 2026, 9:30 AM" in en-US.
{
dateStyle: "medium",
timeStyle: "short",
}locale?stringDefault "en-US".
en-UShourCycle?12 | 2412 adds an AM/PM wheel; 24 shows 00–23. Also sets the label's clock. Default 12.
12minuteStep?numberMinute wheel granularity. Default 5.
5initialTime?TimeValueWhere the wheels start while the field is empty. Default 09:00.
{ hours: 9, minutes: 0 }minDate?DateEarliest moment. Days before it are disabled, and a committed value earlier on that same day is clamped up to it.
—maxDate?DateLatest moment, clamped the same way.
—isDateDisabled?((date: Date) => boolean)—weekStartsOn?0 | 1 | 2 | 3 | 4 | 5 | 6—align?"end" | "start"startdisabled?boolean—className?string—aria-label?stringChoose date and timeMonth Picker
month-picker.tsxTwelve-month grid with a gliding selection pill, year slides and a zoom-out year grid, single or range mode, min/max and full keyboard navigation, plus a popover month field.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/date-picker
import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
/** A span of months; both ends are the first of their month. */
export type MonthRange = { from: Date; to?: Date };
type MonthPickerBaseProps = {
/** Earliest selectable month (any day inside it). */
minDate?: Date;
/** Latest selectable month (any day inside it). */
maxDate?: Date;
/** Extra per-month rule; receives the first of the month. */
isMonthDisabled?: (month: Date) => boolean;
/** Year shown first when nothing is selected. Pass it explicitly for SSR —
* without it the current year is read after mount. */
defaultYear?: number;
/** BCP 47 locale for month names. Default "en-US". */
locale?: string;
className?: string;
"aria-label"?: string;
};
export type MonthPickerSingleProps = MonthPickerBaseProps & {
mode?: "single";
/** First of the selected month. */
value?: Date | null;
defaultValue?: Date | null;
/** Called with the first of the picked month. */
onValueChange?: (month: Date) => void;
};
export type MonthPickerRangeProps = MonthPickerBaseProps & {
mode: "range";
value?: MonthRange | null;
defaultValue?: MonthRange | null;
onValueChange?: (range: MonthRange) => void;
};
export type MonthPickerProps = MonthPickerSingleProps | MonthPickerRangeProps;
// ── month helpers (local time, month precision) ───────────────────────────
const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
const addMonths = (d: Date, n: number) =>
new Date(d.getFullYear(), d.getMonth() + n, 1);
const sameMonth = (a?: Date | null, b?: Date | null) =>
!!a &&
!!b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth();
const monthKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
/** First year of the 12-year page that holds `year`. */
const pageStart = (year: number) => Math.floor(year / 12) * 12;
const COLS = 3;
// Year/page swaps reuse the calendar's directional slide: a short travel in
// the direction you move, with the exit quicker than the entry. Switching
// between the month and year grids zooms instead, since it changes depth
// rather than position. Transform + opacity only.
const SLIDE_ENTER = { duration: 0.26, ease: EASE_OUT } as const;
const SLIDE_EXIT = { duration: 0.16, ease: EASE_OUT } as const;
type Motion = { dir: number; zoom: number; reduce: boolean };
const gridVariants = {
enter: ({ dir, zoom, reduce }: Motion) => ({
opacity: 0,
x: reduce || zoom ? 0 : dir * 28,
scale: reduce || !zoom ? 1 : zoom > 0 ? 0.92 : 1.08,
}),
center: { opacity: 1, x: 0, scale: 1, transition: SLIDE_ENTER },
exit: ({ dir, zoom, reduce }: Motion) => ({
opacity: 0,
x: reduce || zoom ? 0 : dir * -28,
scale: reduce || !zoom ? 1 : zoom > 0 ? 1.08 : 0.92,
transition: SLIDE_EXIT,
}),
};
/**
* Month grid for one year (3 × 4), with a selection pill that glides between
* months on a shared-layout spring. Prev/next slide to neighbouring years;
* clicking the year zooms out to a 12-year grid. Single month or month range,
* min/max and per-month disabling, roving-focus keyboard navigation (arrows,
* Home/End per row, PageUp/PageDown by year — by 12 years in the year grid,
* Escape back to months). Values are the first of the month. Controlled or
* uncontrolled; reduced-motion safe.
*/
export function MonthPicker(props: MonthPickerProps) {
const {
minDate,
maxDate,
isMonthDisabled,
defaultYear,
locale = "en-US",
className,
"aria-label": ariaLabel = "Choose month",
} = props;
const isRange = props.mode === "range";
const uid = useId();
const reduce = useReducedMotion() ?? false;
// ── selection ──
const [internalValue, setInternalValue] = useState<
Date | MonthRange | null
>(props.defaultValue ?? null);
const valueControlled = props.value !== undefined;
const value = valueControlled ? (props.value ?? null) : internalValue;
const single = isRange ? null : (value as Date | null);
const rawRange = isRange ? (value as MonthRange | null) : null;
// Compare at month precision even if a caller hands in mid-month dates.
const range = rawRange
? {
from: startOfMonth(rawRange.from),
to: rawRange.to ? startOfMonth(rawRange.to) : undefined,
}
: null;
const anchor = single ?? range?.from ?? null;
// ── visible year ──
// Without a selection or `defaultYear` the year is unknown until mount, so
// the server and the first client render agree; the grid then shows the
// viewer's current year.
const [year, setYearState] = useState<number | null>(
anchor?.getFullYear() ?? defaultYear ?? null,
);
const [today, setToday] = useState<Date | null>(null);
useEffect(() => {
const now = startOfMonth(new Date());
setToday(now);
setYearState((y) => y ?? now.getFullYear());
}, []);
const shownYear = year ?? defaultYear ?? 2026;
const [view, setView] = useState<"months" | "years">("months");
const [page, setPage] = useState(() => pageStart(shownYear));
const [motionState, setMotionState] = useState({ dir: 1, zoom: 0 });
// ── roving focus ──
const [focused, setFocused] = useState<Date>(() =>
anchor ? startOfMonth(anchor) : new Date(shownYear, 0, 1),
);
const [focusedYear, setFocusedYear] = useState(shownYear);
const gridRef = useRef<HTMLDivElement>(null);
const shouldFocus = useRef(false);
const [hovered, setHovered] = useState<Date | null>(null);
const focusMonth =
focused.getFullYear() === shownYear
? focused
: anchor && anchor.getFullYear() === shownYear
? startOfMonth(anchor)
: new Date(shownYear, 0, 1);
const focusYear =
focusedYear >= page && focusedYear < page + 12 ? focusedYear : page;
useEffect(() => {
if (!shouldFocus.current) return;
shouldFocus.current = false;
const sel =
view === "months"
? `[data-month="${monthKey(focusMonth)}"]`
: `[data-year="${focusYear}"]`;
gridRef.current?.querySelector<HTMLButtonElement>(sel)?.focus();
});
const minMonth = minDate ? startOfMonth(minDate) : null;
const maxMonth = maxDate ? startOfMonth(maxDate) : null;
const isDisabled = (m: Date) =>
(!!minMonth && m < minMonth) ||
(!!maxMonth && m > maxMonth) ||
!!isMonthDisabled?.(m);
const yearDisabled = (y: number) =>
(!!minMonth && y < minMonth.getFullYear()) ||
(!!maxMonth && y > maxMonth.getFullYear());
const goYear = (next: number) => {
if (next === shownYear) return;
setMotionState({ dir: next > shownYear ? 1 : -1, zoom: 0 });
setYearState(next);
};
const goPage = (next: number) => {
if (next === page) return;
setMotionState({ dir: next > page ? 1 : -1, zoom: 0 });
setPage(next);
};
const openYears = () => {
setMotionState({ dir: 1, zoom: 1 });
setPage(pageStart(shownYear));
setFocusedYear(shownYear);
setView("years");
shouldFocus.current = true;
};
const closeYears = (y: number) => {
setMotionState({ dir: 1, zoom: -1 });
setYearState(y);
setFocused(new Date(y, focusMonth.getMonth(), 1));
setView("months");
shouldFocus.current = true;
};
const select = (m: Date) => {
if (isDisabled(m)) return;
setFocused(m);
if (!isRange) {
if (!valueControlled) setInternalValue(m);
(props as MonthPickerSingleProps).onValueChange?.(m);
return;
}
// First click starts a range; a later month closes it; an earlier month
// or a click on a finished range starts over.
const next: MonthRange =
range && !range.to && m >= range.from
? { from: range.from, to: m }
: { from: m };
if (!valueControlled) setInternalValue(next);
(props as MonthPickerRangeProps).onValueChange?.(next);
};
const onMonthKeyDown = (e: KeyboardEvent) => {
const base = focusMonth;
const col = base.getMonth() % COLS;
const delta: Record<string, number> = {
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -COLS,
ArrowDown: COLS,
Home: -col,
End: COLS - 1 - col,
PageUp: -12,
PageDown: 12,
};
if (!(e.key in delta)) return;
e.preventDefault();
const next = addMonths(base, delta[e.key]);
shouldFocus.current = true;
setFocused(next);
goYear(next.getFullYear());
};
const onYearKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// Stop the surrounding popover (if any) from closing as well.
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
closeYears(shownYear);
return;
}
const col = (focusYear - page) % COLS;
const delta: Record<string, number> = {
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -COLS,
ArrowDown: COLS,
Home: -col,
End: COLS - 1 - col,
PageUp: -12,
PageDown: 12,
};
if (!(e.key in delta)) return;
e.preventDefault();
const next = focusYear + delta[e.key];
shouldFocus.current = true;
setFocusedYear(next);
goPage(pageStart(next));
};
// ── labels ──
const monthNames = useMemo(() => {
const short = new Intl.DateTimeFormat(locale, { month: "short" });
const long = new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
});
return {
short: (d: Date) => short.format(d),
long: (d: Date) => long.format(d),
};
}, [locale]);
const prevDisabled =
view === "months"
? !!minMonth && minMonth.getFullYear() >= shownYear
: !!minMonth && minMonth.getFullYear() >= page;
const nextDisabled =
view === "months"
? !!maxMonth && maxMonth.getFullYear() <= shownYear
: !!maxMonth && maxMonth.getFullYear() <= page + 11;
const bandEnd =
range && !range.to && hovered && hovered > range.from
? hovered
: range?.to;
const navButton =
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40";
const cell =
"relative isolate flex h-10 w-full items-center justify-center rounded-lg text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
const gridKey = view === "months" ? `m-${shownYear}` : `y-${page}`;
const custom: Motion = { ...motionState, reduce };
const headerLabel =
view === "months" ? String(shownYear) : `${page} – ${page + 11}`;
return (
<div
className={cn(
"w-72 rounded-2xl border border-border bg-card p-3 text-foreground",
className,
)}
>
<div className="mb-2 flex items-center justify-between gap-2">
<button
type="button"
aria-label={view === "months" ? "Previous year" : "Previous years"}
disabled={prevDisabled}
onClick={() =>
view === "months" ? goYear(shownYear - 1) : goPage(page - 12)
}
className={navButton}
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
aria-label={
view === "months"
? `${shownYear}, choose year`
: `${headerLabel}, back to months`
}
onClick={() => (view === "months" ? openYears() : closeYears(shownYear))}
className="inline-flex h-8 items-center gap-1 rounded-lg px-2 text-sm font-medium tabular-nums outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<span aria-live="polite" className={cn(year === null && "invisible")}>
{headerLabel}
</span>
<motion.span
aria-hidden
animate={{ rotate: view === "years" ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_PRESS}
className="inline-flex text-muted-foreground"
>
<ChevronDown className="h-3.5 w-3.5" />
</motion.span>
</button>
<button
type="button"
aria-label={view === "months" ? "Next year" : "Next years"}
disabled={nextDisabled}
onClick={() =>
view === "months" ? goYear(shownYear + 1) : goPage(page + 12)
}
className={navButton}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div
ref={gridRef}
// Padded so focus rings on edge cells aren't clipped by the slide mask.
className="relative -m-1 overflow-hidden p-1"
onPointerLeave={() => setHovered(null)}
>
<AnimatePresence initial={false} mode="popLayout" custom={custom}>
<motion.div
key={gridKey}
custom={custom}
variants={gridVariants}
initial="enter"
animate="center"
exit="exit"
role="group"
aria-label={
view === "months" ? `${ariaLabel}, ${shownYear}` : "Choose year"
}
onKeyDown={view === "months" ? onMonthKeyDown : onYearKeyDown}
className={cn(
"grid grid-cols-3 gap-1",
// Unknown year until mount (see above): hold the grid back
// rather than flash a placeholder year.
year === null && "invisible",
)}
>
{view === "months"
? Array.from({ length: 12 }, (_, i) => {
const m = new Date(shownYear, i, 1);
const disabled = isDisabled(m);
const isFrom = sameMonth(m, range?.from);
const isTo = sameMonth(m, bandEnd);
const inBand =
!!range?.from && !!bandEnd && m > range.from && m < bandEnd;
const selected = isRange
? isFrom || (isTo && !!range?.to)
: sameMonth(m, single);
const isNow = sameMonth(m, today);
return (
<button
key={monthKey(m)}
type="button"
data-month={monthKey(m)}
tabIndex={sameMonth(m, focusMonth) ? 0 : -1}
aria-label={monthNames.long(m)}
aria-pressed={selected}
aria-disabled={disabled || undefined}
aria-current={isNow ? "date" : undefined}
onClick={() => select(m)}
onFocus={() => setFocused(m)}
onPointerEnter={() => isRange && setHovered(m)}
className={cn(
cell,
inBand && "bg-primary/10",
!selected && !inBand && !disabled && "hover:bg-muted",
selected ? "text-primary-foreground" : "text-foreground",
isTo && !range?.to && "text-foreground",
disabled &&
"cursor-not-allowed text-muted-foreground/40 line-through",
)}
>
{selected &&
(isRange ? (
<motion.span
aria-hidden
initial={reduce ? false : { scale: 0.7 }}
animate={{ scale: 1 }}
transition={SPRING_PRESS}
className="absolute inset-0 -z-10 rounded-lg bg-primary"
/>
) : (
<motion.span
aria-hidden
// Scoped per year so the pill glides between
// months but never across the sliding grids.
layoutId={`${uid}-${shownYear}-month`}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className="absolute inset-0 -z-10 rounded-lg bg-primary"
/>
))}
{isTo && !range?.to && (
<span
aria-hidden
className="absolute inset-0 -z-10 rounded-lg border border-primary/40"
/>
)}
{monthNames.short(m)}
{isNow && (
<span
aria-hidden
className={cn(
"absolute bottom-1.5 size-1 rounded-full",
selected ? "bg-primary-foreground" : "bg-primary",
)}
/>
)}
</button>
);
})
: Array.from({ length: 12 }, (_, i) => {
const y = page + i;
const disabled = yearDisabled(y);
const selected = y === shownYear;
return (
<button
key={y}
type="button"
data-year={y}
tabIndex={y === focusYear ? 0 : -1}
aria-pressed={selected}
aria-disabled={disabled || undefined}
aria-current={today?.getFullYear() === y ? "date" : undefined}
onClick={() => !disabled && closeYears(y)}
onFocus={() => setFocusedYear(y)}
className={cn(
cell,
"tabular-nums",
selected ? "text-primary-foreground" : "text-foreground",
!selected && !disabled && "hover:bg-muted",
disabled &&
"cursor-not-allowed text-muted-foreground/40 line-through",
)}
>
{selected && (
<motion.span
aria-hidden
layoutId={`${uid}-${page}-year`}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className="absolute inset-0 -z-10 rounded-lg bg-primary"
/>
)}
{y}
</button>
);
})}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
const MONTH_YEAR: Intl.DateTimeFormatOptions = {
month: "long",
year: "numeric",
};
export interface MonthFieldProps
extends Omit<
MonthPickerSingleProps,
"mode" | "value" | "defaultValue" | "onValueChange" | "className"
> {
value?: Date | null;
defaultValue?: Date | null;
onValueChange?: (month: Date) => void;
placeholder?: string;
/** Intl options for the trigger label. Default "October 2026". */
formatOptions?: Intl.DateTimeFormatOptions;
align?: "start" | "end";
disabled?: boolean;
className?: string;
}
/**
* Month field whose month grid morphs open from the trigger corner and closes
* as soon as a month is picked. Controlled or uncontrolled.
*/
export function MonthField({
value: controlledValue,
defaultValue = null,
onValueChange,
placeholder = "Pick a month",
formatOptions = MONTH_YEAR,
locale = "en-US",
align = "start",
disabled,
className,
"aria-label": ariaLabel = "Choose month",
...pickerProps
}: MonthFieldProps) {
const [open, setOpen] = useState(false);
const [internal, setInternal] = useState<Date | null>(defaultValue);
const value = controlledValue !== undefined ? controlledValue : internal;
const label = useMemo(
() =>
value ? new Intl.DateTimeFormat(locale, formatOptions).format(value) : null,
[value, locale, formatOptions],
);
return (
<MorphPopover open={open} onOpenChange={setOpen}>
<MorphPopoverTrigger>
<button
type="button"
disabled={disabled}
aria-label={label ? `${ariaLabel}, ${label}` : ariaLabel}
className={cn(
"inline-flex h-10 min-w-44 items-center gap-2 rounded-xl border border-border bg-background px-3 text-left text-sm outline-none transition-colors hover:border-border-strong focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
label ? "text-foreground" : "text-muted-foreground",
className,
)}
>
<CalendarDays className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate tabular-nums">
{label ?? placeholder}
</span>
</button>
</MorphPopoverTrigger>
<MorphPopoverContent align={align} className="p-0">
<MonthPicker
{...pickerProps}
aria-label={ariaLabel}
locale={locale}
value={value}
onValueChange={(m) => {
if (controlledValue === undefined) setInternal(m);
onValueChange?.(m);
setOpen(false);
}}
className="border-0 bg-transparent"
/>
</MorphPopoverContent>
</MorphPopover>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
/** The element the panel measures against — see `registerTrigger`. */
triggerRef: React.MutableRefObject<HTMLElement | null>;
registerTrigger: (node: HTMLElement | null) => void;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
// A trigger normally registers itself through MorphPopoverTrigger. It can't
// when something else already clones the element — a Tooltip wrapping the
// button, say — and an unregistered trigger leaves the panel with nothing to
// measure against, so it renders permanently invisible. The root boxes the
// trigger exactly (the content portals out of it), so it stands in until a
// real trigger registers, and stands in again if that one unmounts. Both are
// state, so a trigger arriving while the panel is open re-anchors it.
const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(
() => ({ current: trigger ?? root }),
[root, trigger],
);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: a dismissal hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded. When no trigger has
// registered, the root anchor stands in only if it can actually hold focus;
// there is nowhere better than where the keyboard already is, so leave it.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (!inPanel) return;
const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);
restore?.focus();
}, [root, setOpen, trigger]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
root &&
!root.contains(target) &&
!contentRef.current?.contains(target)
)
close();
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, root, close]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef: anchorRef,
registerTrigger: setTrigger,
contentRef,
}),
[open, setOpen, toggle, baseId, anchorRef],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={setRoot} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as React.MutableRefObject<T | null>).current = node;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergeRefs(childRef, ctx.registerTrigger),
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipHidden(side: Side, align: Align, radius: number) {
const top = side === "bottom" ? "0%" : "92%";
const bottom = side === "bottom" ? "92%" : "0%";
const right = align === "end" ? "0%" : "92%";
const left = align === "end" ? "92%" : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
const clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
className?: string;
}
export function MorphPopoverContent({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [portalReady, setPortalReady] = useState(false);
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
portalReady && ctx.open,
);
useEffect(() => setPortalReady(true), []);
const left = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
const top = layout
? side === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: layout.trigger.top - layout.content.height - sideOffset
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },
show: { opacity: 1, scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipHidden(side, align, radius),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipShown(radius),
transition: MORPH_CLIP_TRANSITION,
},
};
// Keep the server and first client render identical, then mount the portal.
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open ? (
<motion.div
data-morph-popover-portal=""
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "show"}
exit={reduce ? { opacity: 0 } : "hidden"}
transition={reduce ? { duration: 0.12 } : undefined}
style={{
left,
top,
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(side, align),
}}
className="fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]"
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role="dialog"
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
API Reference
MonthPicker
minDate?DateEarliest selectable month (any day inside it).
—maxDate?DateLatest selectable month (any day inside it).
—isMonthDisabled?((month: Date) => boolean)Extra per-month rule; receives the first of the month.
—defaultYear?numberYear shown first when nothing is selected. Pass it explicitly for SSR — without it the current year is read after mount.
—locale?stringBCP 47 locale for month names. Default "en-US".
—className?string—aria-label?string—mode?"range" | "single"—value?Date | MonthRange | nullFirst of the selected month.
—defaultValue?Date | MonthRange | null—onValueChange?((month: Date) => void) | ((range: MonthRange) => void)Called with the first of the picked month.
—MonthField
value?Date | null—defaultValue?Date | nullnullonValueChange?((month: Date) => void)—placeholder?stringPick a monthformatOptions?DateTimeFormatOptionsIntl options for the trigger label. Default "October 2026".
{
month: "long",
year: "numeric",
}align?"end" | "start"startdisabled?boolean—className?string—aria-label?stringChoose monthminDate?DateEarliest selectable month (any day inside it).
—maxDate?DateLatest selectable month (any day inside it).
—isMonthDisabled?((month: Date) => boolean)Extra per-month rule; receives the first of the month.
—defaultYear?numberYear shown first when nothing is selected. Pass it explicitly for SSR — without it the current year is read after mount.
—locale?stringBCP 47 locale for month names. Default "en-US".
en-USUpdated