Date Range Picker
Composable date range picker with root, trigger, popover content, calendar, header, grid, summary, footer, clear and preset parts, plus a shared state hook. Compact inline and dropdown compositions, direct month and year selection, and full keyboard navigation.
Preview
"use client";
import { useState } from "react";
import {
DateRangePicker,
DateRangePickerCalendar,
DateRangePickerClear,
DateRangePickerContent,
DateRangePickerFooter,
DateRangePickerGrid,
DateRangePickerHeader,
DateRangePickerSelection,
DateRangePickerTrigger,
} from "@/components/motion/date-range-picker";
export function DateRangePickerPreview() {
const [period] = useState(() => {
const now = new Date();
const date = (daysAgo: number) => {
const day = new Date(now);
day.setDate(day.getDate() - daysAgo);
return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, "0")}-${String(day.getDate()).padStart(2, "0")}`;
};
return {
today: date(0),
weekStart: date(6),
};
});
return (
<DateRangePicker
label="Reporting period"
defaultMonth={period.today}
defaultValue={{ from: period.weekStart, to: period.today }}
max={period.today}
>
<DateRangePickerTrigger />
<DateRangePickerContent>
<DateRangePickerCalendar className="w-full rounded-none border-0">
<DateRangePickerHeader />
<DateRangePickerGrid />
<DateRangePickerFooter>
<div className="flex items-center justify-between gap-2">
<DateRangePickerSelection />
<DateRangePickerClear />
</div>
</DateRangePickerFooter>
</DateRangePickerCalendar>
</DateRangePickerContent>
</DateRangePicker>
);
}
"use client";
// beui.dev/components/motion/date-range-picker
import {
ArrowRight,
CalendarDays,
ChevronDown,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useIsPresent,
} from "motion/react";
import {
type ComponentPropsWithRef,
type ComponentPropsWithoutRef,
type KeyboardEvent,
type ReactNode,
useCallback,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
import {
MorphPopover,
MorphPopoverContent,
type MorphPopoverContentProps,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import {
DateRangePickerContext,
useDateRangePickerContext,
} from "./date-range-picker/context";
import { parse } from "./date-range-picker/date-utils";
import { useDateRangePickerController } from "./date-range-picker/use-date-range-picker";
import type {
DateRange,
DateRangePickerProps,
} from "./date-range-picker/types";
export type {
DateRange,
DateRangePreset,
DateRangePickerProps,
} from "./date-range-picker/types";
const monthVariants = {
enter: ({ direction, reduce }: { direction: number; reduce: boolean }) => ({
opacity: 0,
x: reduce ? 0 : direction * 12,
}),
exit: ({ direction, reduce }: { direction: number; reduce: boolean }) => ({
opacity: 0,
x: reduce ? 0 : direction * -8,
transition: { duration: 0.12, ease: EASE_OUT },
}),
};
const iconClass =
"inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-30";
/** State shared by built-in parts and custom date range controls. */
export function useDateRangePicker() {
const ctx = useDateRangePickerContext("useDateRangePicker");
return {
value: ctx.value,
setValue: ctx.change,
open: ctx.open,
setOpen: ctx.setOpen,
month: ctx.month,
view: ctx.view,
label: ctx.label,
locale: ctx.locale,
disabled: ctx.disabled,
selectDate: ctx.select,
clear: ctx.clear,
goToMonth: ctx.goToMonth,
previous: () => !ctx.previousDisabled && ctx.page(-1),
next: () => !ctx.nextDisabled && ctx.page(1),
previousDisabled: ctx.previousDisabled,
nextDisabled: ctx.nextDisabled,
chooseMonth: () => ctx.openChoices("months"),
chooseYear: () => ctx.openChoices("years"),
};
}
/** Range state and popover root. Without children, renders the inline calendar. */
export function DateRangePicker({
children,
className,
...props
}: DateRangePickerProps) {
const ctx = useDateRangePickerController(props);
return (
<DateRangePickerContext.Provider value={ctx}>
<MorphPopover
open={ctx.open}
onOpenChange={ctx.setOpen}
className={cn("max-w-full", children !== undefined && className)}
>
{children === undefined ? (
<DateRangePickerCalendar className={className} />
) : (
children
)}
</MorphPopover>
</DateRangePickerContext.Provider>
);
}
export interface DateRangePickerCalendarProps
extends ComponentPropsWithoutRef<"section"> {
showSummary?: boolean;
}
/** Calendar surface; arrange or omit its summary, header, grid and footer. */
export function DateRangePickerCalendar({
children,
className,
showSummary,
...props
}: DateRangePickerCalendarProps) {
const ctx = useDateRangePickerContext("DateRangePickerCalendar");
return (
<section
{...props}
aria-label={props["aria-label"] ?? ctx.label}
className={cn(
"w-80 max-w-full rounded-2xl border border-border bg-background p-3.5",
className,
)}
>
<h3 id={ctx.heading} aria-live="polite" className="sr-only">
{ctx.monthFormat.format(parse(ctx.month))}
</h3>
{children === undefined ? (
<>
{(showSummary ?? ctx.showSummary) && <DateRangePickerSummary />}
<DateRangePickerHeader />
<DateRangePickerGrid />
<DateRangePickerFooter />
</>
) : (
children
)}
<p id={ctx.help} className="sr-only">
Use arrow keys to navigate dates, Home and End for the week, Page Up and
Page Down for months, Shift with Page Up or Page Down for years. Enter
or Space selects a date. Use the month and year buttons to jump
directly. In the month or year choices, use arrow keys to move and
Escape to return.
</p>
<span role="status" className="sr-only">
{ctx.announcement}
</span>
</section>
);
}
export type DateRangePickerSummaryProps = Omit<
ComponentPropsWithoutRef<"div">,
"children"
>;
export function DateRangePickerSummary({
className,
...props
}: DateRangePickerSummaryProps) {
const { value, shortFormat } = useDateRangePickerContext(
"DateRangePickerSummary",
);
return (
<div
{...props}
className={cn(
"mb-2.5 grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2",
className,
)}
>
<div className="min-w-0">
<p className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Start date
</p>
<p className="text-[13px] font-medium tabular-nums">
{value ? shortFormat.format(parse(value.from)) : "Select date"}
</p>
</div>
<ArrowRight
size={14}
className="text-muted-foreground/60"
aria-hidden="true"
/>
<div className="min-w-0">
<p className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
End date
</p>
<p
className={cn(
"text-[13px] font-medium tabular-nums",
!value?.to && "text-muted-foreground",
)}
>
{value?.to ? shortFormat.format(parse(value.to)) : "Select date"}
</p>
</div>
</div>
);
}
export type DateRangePickerHeaderProps = Omit<
ComponentPropsWithoutRef<"div">,
"children"
>;
export function DateRangePickerHeader({
className,
...props
}: DateRangePickerHeaderProps) {
const {
monthTrigger,
yearTrigger,
view,
choicesId,
disabled,
openChoices,
monthNameFormat,
month,
yearPage,
yearPageEnd,
year,
previousDisabled,
nextDisabled,
page,
} = useDateRangePickerContext("DateRangePickerHeader");
return (
<div
{...props}
className={cn("mb-2 flex items-center justify-between gap-1", className)}
>
<div className="flex min-w-0 items-center gap-0.5">
<button
ref={monthTrigger}
type="button"
aria-label="Choose month"
aria-expanded={view === "months"}
aria-controls={view === "months" ? choicesId : undefined}
disabled={disabled}
onClick={() => openChoices("months")}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-[13px] font-semibold transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40",
view === "years" && "hidden",
)}
>
{monthNameFormat.format(parse(month))}
<ChevronDown
size={12}
aria-hidden="true"
className={cn(
"text-muted-foreground",
view === "months" && "rotate-180",
)}
/>
</button>
<button
ref={yearTrigger}
type="button"
aria-label="Choose year"
aria-expanded={view === "years"}
aria-controls={view === "years" ? choicesId : undefined}
disabled={disabled}
onClick={() => openChoices("years")}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-[13px] font-medium tabular-nums text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
>
{view === "years" ? `${yearPage}–${yearPageEnd}` : year}
<ChevronDown
size={12}
aria-hidden="true"
className={cn(view === "years" && "rotate-180")}
/>
</button>
</div>
<div className="flex shrink-0 gap-0.5">
<button
type="button"
aria-label={
view === "years"
? "Previous years"
: view === "months"
? "Previous year"
: "Previous month"
}
disabled={previousDisabled}
className={iconClass}
onClick={() => page(-1)}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<button
type="button"
aria-label={
view === "years"
? "Next years"
: view === "months"
? "Next year"
: "Next month"
}
disabled={nextDisabled}
hidden={view === "years" && nextDisabled}
className={cn(
iconClass,
view === "years" && nextDisabled && "hidden",
)}
onClick={() => page(1)}
>
<ChevronRight size={16} aria-hidden="true" />
</button>
</div>
</div>
);
}
export type DateRangePickerGridProps = Omit<
ComponentPropsWithoutRef<"div">,
"children"
>;
export function DateRangePickerGrid({
className,
onPointerLeave,
...props
}: DateRangePickerGridProps) {
const {
setPreview,
view,
month,
yearPage,
yearPageEnd,
year,
direction,
reduce,
choicesId,
choices,
focusChoices,
closeChoices,
goToMonth,
grid,
heading,
help,
active,
locale,
weeks,
shownRange,
value,
canHover,
unavailable,
dateFormat,
today,
select,
setCursor,
onKey,
} = useDateRangePickerContext("DateRangePickerGrid");
return (
<div
{...props}
className={cn("overflow-hidden", className)}
onPointerLeave={(event) => {
onPointerLeave?.(event);
setPreview(null);
}}
>
<AnimatePresence
initial={false}
mode="popLayout"
custom={{ direction, reduce }}
>
<motion.div
key={
view === "days"
? month
: `${view}-${view === "years" ? yearPage : year}`
}
custom={{ direction, reduce }}
variants={monthVariants}
initial="enter"
animate={{ opacity: 1, x: 0 }}
exit="exit"
transition={{ duration: reduce ? 0.1 : 0.18, ease: EASE_OUT }}
>
<CalendarPresence>
{view !== "days" ? (
<CalendarChoices
id={choicesId}
label={
view === "months"
? `Choose a month in ${year}`
: `Choose a year from ${yearPage} to ${yearPageEnd}`
}
choices={choices}
selected={view === "months" ? month : String(year)}
focusOnMount={focusChoices}
reduce={reduce}
onClose={closeChoices}
onSelect={(id) =>
goToMonth(
view === "months"
? id
: `${id.padStart(4, "0")}-${month.slice(5, 7)}-01`,
)
}
/>
) : (
<table
ref={(node) => {
if (node) {
grid.current = node;
return () => {
if (grid.current === node) grid.current = null;
};
}
}}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: WAI calendar grids retain native table structure with an interactive grid role.
role="grid"
aria-labelledby={heading}
aria-describedby={help}
tabIndex={active === null ? 0 : undefined}
className="w-full table-fixed select-none border-separate border-spacing-x-0 border-spacing-y-1"
>
<thead>
<tr>
{Array.from({ length: 7 }, (_, index) => {
const day = new Intl.DateTimeFormat(locale, {
weekday: "long",
timeZone: "UTC",
}).format(Date.UTC(2026, 0, 4 + index));
return (
<th
key={day}
scope="col"
abbr={day}
className="h-6 pb-1.5 text-center text-[11px] font-medium text-muted-foreground"
>
{new Intl.DateTimeFormat(locale, {
weekday: "short",
timeZone: "UTC",
}).format(Date.UTC(2026, 0, 4 + index))}
</th>
);
})}
</tr>
</thead>
<tbody>
{weeks.map(({ start, dates: week }) => (
<tr key={start}>
{week.map((date, dayIndex) => {
const inRange = Boolean(
date &&
shownRange &&
date >= shownRange.from &&
date <= (shownRange.to ?? shownRange.from),
);
const endpoint = Boolean(
date &&
value &&
(date === value.from || date === value.to),
);
return (
// biome-ignore lint/a11y/useFocusableInteractive: Calendar cells delegate focus to the contained day button.
<td
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: Gridcell selection semantics belong on the native table cell.
role="gridcell"
key={date ?? `empty-${dayIndex}`}
aria-selected={
date
? Boolean(
value &&
date >= value.from &&
date <= (value.to ?? value.from),
)
: undefined
}
onPointerEnter={(event) => {
if (
canHover &&
event.pointerType !== "touch" &&
date &&
!unavailable(date) &&
value &&
!value.to
)
setPreview(date);
}}
className={cn(
"relative h-8 p-0 text-center",
inRange &&
(dayIndex === 0 || date === shownRange?.from) &&
"rounded-l-full",
inRange &&
(dayIndex === 6 ||
date === shownRange?.to ||
!shownRange?.to) &&
"rounded-r-full",
)}
>
{date && (
<span
aria-hidden="true"
// Range scrubbing follows immediately: independent cell
// fades leave stale highlights behind a moving pointer.
className={cn(
"pointer-events-none absolute inset-y-0 rounded-[inherit] bg-foreground/[0.08]",
inRange ? "opacity-100" : "opacity-0",
)}
style={{
left:
endpoint &&
(date === shownRange?.from ||
!week[dayIndex - 1])
? "50%"
: 0,
right:
endpoint &&
(date ===
(shownRange?.to ?? shownRange?.from) ||
!week[dayIndex + 1])
? "50%"
: 0,
}}
/>
)}
{date && (
<motion.button
type="button"
disabled={unavailable(date)}
aria-label={dateFormat.format(parse(date))}
aria-current={
date === today ? "date" : undefined
}
tabIndex={date === active ? 0 : -1}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
onClick={() => select(date)}
onFocus={() => {
setCursor(date);
if (value && !value.to) setPreview(date);
}}
onKeyDown={(event) => onKey(event, date)}
className={cn(
"relative mx-auto flex h-8 w-full max-w-8 items-center justify-center rounded-full text-xs tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-30",
endpoint
? "bg-foreground font-medium text-background"
: canHover &&
!unavailable(date) &&
!inRange &&
"hover:bg-muted",
date === today &&
!endpoint &&
"font-semibold after:absolute after:bottom-1 after:size-0.75 after:rounded-full after:bg-current",
)}
>
{new Date(parse(date)).getUTCDate()}
</motion.button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
)}
</CalendarPresence>
</motion.div>
</AnimatePresence>
</div>
);
}
export type DateRangePickerSelectionProps = ComponentPropsWithoutRef<"p">;
export function DateRangePickerSelection({
children,
className,
...props
}: DateRangePickerSelectionProps) {
const { value, dayCount } = useDateRangePickerContext(
"DateRangePickerSelection",
);
return (
<p {...props} className={cn("text-xs text-muted-foreground", className)}>
{children ??
(dayCount
? `${dayCount} ${dayCount === 1 ? "day" : "days"} selected`
: value
? "Choose an end date"
: "Choose a start date")}
</p>
);
}
export type DateRangePickerClearProps = ComponentPropsWithRef<"button">;
export function DateRangePickerClear({
children = "Clear",
className,
onClick,
disabled,
...props
}: DateRangePickerClearProps) {
const ctx = useDateRangePickerContext("DateRangePickerClear");
return (
<button
{...props}
type="button"
aria-label={props["aria-label"] ?? "Clear date range"}
disabled={disabled || ctx.disabled || !ctx.value}
className={cn(
"rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-30",
className,
)}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) ctx.clear();
}}
>
{children}
</button>
);
}
export type DateRangePickerFooterProps = ComponentPropsWithoutRef<"div">;
export function DateRangePickerFooter({
children,
className,
...props
}: DateRangePickerFooterProps) {
return (
<div
{...props}
className={cn("mt-2.5 border-t border-border pt-2.5", className)}
>
{children === undefined ? (
<>
<div className="flex items-center justify-between gap-2">
<DateRangePickerSelection />
<DateRangePickerClear />
</div>
<DateRangePickerPresets />
</>
) : (
children
)}
</div>
);
}
export type DateRangePickerPresetsProps = ComponentPropsWithoutRef<"div">;
export function DateRangePickerPresets({
children,
className,
...props
}: DateRangePickerPresetsProps) {
const ctx = useDateRangePickerContext("DateRangePickerPresets");
if (children === undefined && ctx.presets.length === 0) return null;
return (
<div
{...props}
className={cn("mt-2.5 flex flex-wrap justify-center gap-1.5", className)}
>
{children === undefined
? ctx.presets.map((preset) => (
<DateRangePickerPreset
key={preset.label}
value={preset.value}
label={preset.label}
>
{preset.label}
</DateRangePickerPreset>
))
: children}
</div>
);
}
export interface DateRangePickerPresetProps
extends Omit<ComponentPropsWithRef<"button">, "value"> {
value: DateRange;
/** Accessible preset name and selection announcement. */
label: string;
}
export function DateRangePickerPreset({
value,
label,
children,
className,
disabled,
onClick,
...props
}: DateRangePickerPresetProps) {
const ctx = useDateRangePickerContext("DateRangePickerPreset");
const selected = ctx.value?.from === value.from && ctx.value?.to === value.to;
return (
<button
{...props}
type="button"
aria-label={label}
aria-pressed={selected}
disabled={disabled || !ctx.rangeAvailable(value)}
className={cn(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40",
selected
? "bg-foreground text-background"
: "bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground",
className,
)}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) ctx.selectPreset(value, label);
}}
>
{children ?? label}
</button>
);
}
export interface DateRangePickerTriggerProps
extends HTMLMotionProps<"button"> {
placeholder?: string;
}
export function DateRangePickerTrigger({
children,
placeholder = "Select dates",
className,
disabled,
ref,
...props
}: DateRangePickerTriggerProps) {
const ctx = useDateRangePickerContext("DateRangePickerTrigger");
const mergedRef = useCallback(
(node: HTMLButtonElement | null) => {
ctx.trigger.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ctx.trigger, ref],
);
const format = new Intl.DateTimeFormat(ctx.locale, {
month: "short",
day: "numeric",
timeZone: "UTC",
});
const sameYear =
ctx.value?.to && ctx.value.from.slice(0, 4) === ctx.value.to.slice(0, 4);
const summary = ctx.value
? `${(sameYear ? format : ctx.shortFormat).format(parse(ctx.value.from))} – ${ctx.value.to ? ctx.shortFormat.format(parse(ctx.value.to)) : "Select end"}`
: placeholder;
return (
<MorphPopoverTrigger>
<motion.button
{...props}
ref={mergedRef}
layout={!ctx.reduce}
transition={{ layout: SPRING_LAYOUT }}
style={{ borderRadius: 12, ...props.style }}
type="button"
disabled={disabled || ctx.disabled}
aria-label={props["aria-label"] ?? `${ctx.label}: ${summary}`}
className={cn(
"inline-flex h-9 w-fit max-w-full items-center gap-2 rounded-xl border border-border bg-background px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40",
className,
)}
>
{children != null ? (
<motion.span
layout={ctx.reduce ? false : "position"}
transition={{ layout: SPRING_LAYOUT }}
className="inline-flex min-w-0 items-center gap-2"
>
{children}
</motion.span>
) : (
<>
<motion.span
layout={ctx.reduce ? false : "position"}
transition={{ layout: SPRING_LAYOUT }}
className="shrink-0 text-muted-foreground"
aria-hidden="true"
>
<CalendarDays size={15} />
</motion.span>
<motion.span
layout={ctx.reduce ? false : "position"}
transition={{ layout: SPRING_LAYOUT }}
className="min-w-0 truncate tabular-nums"
>
{summary}
</motion.span>
<motion.span
layout={ctx.reduce ? false : "position"}
transition={{ layout: SPRING_LAYOUT }}
className="shrink-0 text-muted-foreground"
aria-hidden="true"
>
<ChevronDown size={13} className={cn(ctx.open && "rotate-180")} />
</motion.span>
</>
)}
</motion.button>
</MorphPopoverTrigger>
);
}
export interface DateRangePickerContentProps extends MorphPopoverContentProps {
/** Move focus to the active calendar date when opened. Default true. */
autoFocus?: boolean;
}
function focusCalendarDate(content: HTMLDivElement | null) {
const grid = Array.from(
content?.querySelectorAll<HTMLElement>('[role="grid"]') ?? [],
).find((element) => !element.closest("[inert]"));
const target =
grid?.querySelector<HTMLElement>('button[tabindex="0"]') ?? grid;
target?.focus({ preventScroll: true });
}
export function DateRangePickerContent({
children,
className,
autoFocus = true,
onOpenAutoFocus,
...props
}: DateRangePickerContentProps) {
const ctx = useDateRangePickerContext("DateRangePickerContent");
useLayoutEffect(() => {
// A rapid reopen can revive the exiting surface before it unmounts.
// That surface already ran its positioned callback, so focus it here too.
if (ctx.open && autoFocus) focusCalendarDate(ctx.content.current);
}, [ctx.open, ctx.content, autoFocus]);
return (
<MorphPopoverContent
align="start"
radius={16}
shadow={false}
{...props}
className={cn("w-80 max-w-[calc(100vw-1.5rem)]", className)}
onOpenAutoFocus={(content) => {
ctx.content.current = content;
if (autoFocus) focusCalendarDate(content);
onOpenAutoFocus?.(content);
}}
>
{children}
</MorphPopoverContent>
);
}
export interface DateRangePickerDropdownProps
extends Omit<DateRangePickerProps, "children"> {
placeholder?: string;
triggerClassName?: string;
calendarClassName?: string;
}
/** Convenience composition of the root, trigger, content and calendar. */
export function DateRangePickerDropdown({
placeholder,
triggerClassName,
calendarClassName,
autoFocus = true,
showSummary = false,
...props
}: DateRangePickerDropdownProps) {
return (
<DateRangePicker {...props} autoFocus={autoFocus} showSummary={showSummary}>
<DateRangePickerTrigger
placeholder={placeholder}
className={triggerClassName}
/>
<DateRangePickerContent autoFocus={autoFocus}>
<DateRangePickerCalendar
className={cn("w-full rounded-none border-0", calendarClassName)}
/>
</DateRangePickerContent>
</DateRangePicker>
);
}
// The departing month's grid must leave the tab order as soon as exit begins.
function CalendarPresence({ children }: { children: ReactNode }) {
const present = useIsPresent();
return <div inert={!present}>{children}</div>;
}
function CalendarChoices({
id,
label,
choices,
selected,
focusOnMount,
reduce,
onClose,
onSelect,
}: {
id: string;
label: string;
choices: { id: string; label: string; disabled: boolean }[];
selected: string;
focusOnMount: boolean;
reduce: boolean;
onClose: () => void;
onSelect: (id: string) => void;
}) {
const enabled = choices.filter((choice) => !choice.disabled);
const [cursor, setCursor] = useState<string | null>(selected);
const active =
enabled.find((choice) => choice.id === cursor)?.id ??
enabled.find((choice) => choice.id === selected)?.id ??
enabled[0]?.id;
if (cursor !== null && cursor !== active) setCursor(active ?? null);
const root = useRef<HTMLFieldSetElement>(null);
useLayoutEffect(() => {
if (focusOnMount)
root.current
?.querySelector<HTMLButtonElement>('button[tabindex="0"]')
?.focus({ preventScroll: true });
}, [focusOnMount]);
const onKey = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const buttons = Array.from(
root.current?.querySelectorAll<HTMLButtonElement>("button") ?? [],
);
const index = buttons.indexOf(event.currentTarget);
const offset =
event.key === "ArrowLeft"
? -1
: event.key === "ArrowRight"
? 1
: event.key === "ArrowUp"
? -3
: event.key === "ArrowDown"
? 3
: null;
let target =
event.key === "Home"
? 0
: event.key === "End"
? buttons.length - 1
: offset === null
? null
: index + offset;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
return;
}
if (target === null) return;
event.preventDefault();
const step =
offset === null ? (event.key === "Home" ? 1 : -1) : Math.sign(offset);
while (target >= 0 && target < buttons.length && buttons[target].disabled)
target += step;
buttons[target]?.focus({ preventScroll: true });
};
return (
<fieldset
ref={root}
id={id}
aria-label={label}
className="grid min-h-[15.25rem] grid-cols-3 content-start gap-2 border-0 p-0 pt-1"
>
{choices.map((choice) => (
<motion.button
key={choice.id}
type="button"
disabled={choice.disabled}
aria-pressed={choice.id === selected}
tabIndex={choice.id === active ? 0 : -1}
onFocus={() => setCursor(choice.id)}
onKeyDown={onKey}
onClick={() => onSelect(choice.id)}
whileTap={reduce ? undefined : { scale: 0.96 }}
transition={SPRING_PRESS}
className={cn(
"flex h-13 items-center justify-center rounded-xl px-1 text-[13px] tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-25",
choice.id === selected
? "bg-foreground font-medium text-background"
: "bg-muted/40 hover:bg-muted",
)}
>
{choice.label}
</motion.button>
))}
</fieldset>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
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;
}
Copy the source code
"use client";
// beui.dev/components/motion/date-range-picker
import {
ArrowRight,
CalendarDays,
ChevronDown,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useIsPresent,
} from "motion/react";
import {
type ComponentPropsWithRef,
type ComponentPropsWithoutRef,
type KeyboardEvent,
type ReactNode,
useCallback,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
import {
MorphPopover,
MorphPopoverContent,
type MorphPopoverContentProps,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import {
DateRangePickerContext,
useDateRangePickerContext,
} from "./date-range-picker/context";
import { parse } from "./date-range-picker/date-utils";
import { useDateRangePickerController } from "./date-range-picker/use-date-range-picker";
import type {
DateRange,
DateRangePickerProps,
} from "./date-range-picker/types";
export type {
DateRange,
DateRangePreset,
DateRangePickerProps,
} from "./date-range-picker/types";
const monthVariants = {
enter: ({ direction, reduce }: { direction: number; reduce: boolean }) => ({
opacity: 0,
x: reduce ? 0 : direction * 12,
}),
exit: ({ direction, reduce }: { direction: number; reduce: boolean }) => ({
opacity: 0,
x: reduce ? 0 : direction * -8,
transition: { duration: 0.12, ease: EASE_OUT },
}),
};
const iconClass =
"inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-30";
/** State shared by built-in parts and custom date range controls. */
export function useDateRangePicker() {
const ctx = useDateRangePickerContext("useDateRangePicker");
return {
value: ctx.value,
setValue: ctx.change,
open: ctx.open,
setOpen: ctx.setOpen,
month: ctx.month,
view: ctx.view,
label: ctx.label,
locale: ctx.locale,
disabled: ctx.disabled,
selectDate: ctx.select,
clear: ctx.clear,
goToMonth: ctx.goToMonth,
previous: () => !ctx.previousDisabled && ctx.page(-1),
next: () => !ctx.nextDisabled && ctx.page(1),
previousDisabled: ctx.previousDisabled,
nextDisabled: ctx.nextDisabled,
chooseMonth: () => ctx.openChoices("months"),
chooseYear: () => ctx.openChoices("years"),
};
}
/** Range state and popover root. Without children, renders the inline calendar. */
export function DateRangePicker({
children,
className,
...props
}: DateRangePickerProps) {
const ctx = useDateRangePickerController(props);
return (
<DateRangePickerContext.Provider value={ctx}>
<MorphPopover
open={ctx.open}
onOpenChange={ctx.setOpen}
className={cn("max-w-full", children !== undefined && className)}
>
{children === undefined ? (
<DateRangePickerCalendar className={className} />
) : (
children
)}
</MorphPopover>
</DateRangePickerContext.Provider>
);
}
export interface DateRangePickerCalendarProps
extends ComponentPropsWithoutRef<"section"> {
showSummary?: boolean;
}
/** Calendar surface; arrange or omit its summary, header, grid and footer. */
export function DateRangePickerCalendar({
children,
className,
showSummary,
...props
}: DateRangePickerCalendarProps) {
const ctx = useDateRangePickerContext("DateRangePickerCalendar");
return (
<section
{...props}
aria-label={props["aria-label"] ?? ctx.label}
className={cn(
"w-80 max-w-full rounded-2xl border border-border bg-background p-3.5",
className,
)}
>
<h3 id={ctx.heading} aria-live="polite" className="sr-only">
{ctx.monthFormat.format(parse(ctx.month))}
</h3>
{children === undefined ? (
<>
{(showSummary ?? ctx.showSummary) && <DateRangePickerSummary />}
<DateRangePickerHeader />
<DateRangePickerGrid />
<DateRangePickerFooter />
</>
) : (
children
)}
<p id={ctx.help} className="sr-only">
Use arrow keys to navigate dates, Home and End for the week, Page Up and
Page Down for months, Shift with Page Up or Page Down for years. Enter
or Space selects a date. Use the month and year buttons to jump
directly. In the month or year choices, use arrow keys to move and
Escape to return.
</p>
<span role="status" className="sr-only">
{ctx.announcement}
</span>
</section>
);
}
export type DateRangePickerSummaryProps = Omit<
ComponentPropsWithoutRef<"div">,
"children"
>;
export function DateRangePickerSummary({
className,
...props
}: DateRangePickerSummaryProps) {
const { value, shortFormat } = useDateRangePickerContext(
"DateRangePickerSummary",
);
return (
<div
{...props}
className={cn(
"mb-2.5 grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2",
className,
)}
>
<div className="min-w-0">
<p className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Start date
</p>
<p className="text-[13px] font-medium tabular-nums">
{value ? shortFormat.format(parse(value.from)) : "Select date"}
</p>
</div>
<ArrowRight
size={14}
className="text-muted-foreground/60"
aria-hidden="true"
/>
<div className="min-w-0">
<p className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
End date
</p>
<p
className={cn(
"text-[13px] font-medium tabular-nums",
!value?.to && "text-muted-foreground",
)}
>
{value?.to ? shortFormat.format(parse(value.to)) : "Select date"}
</p>
</div>
</div>
);
}
export type DateRangePickerHeaderProps = Omit<
ComponentPropsWithoutRef<"div">,
"children"
>;
export function DateRangePickerHeader({
className,
...props
}: DateRangePickerHeaderProps) {
const {
monthTrigger,
yearTrigger,
view,
choicesId,
disabled,
openChoices,
monthNameFormat,
month,
yearPage,
yearPageEnd,
year,
previousDisabled,
nextDisabled,
page,
} = useDateRangePickerContext("DateRangePickerHeader");
return (
<div
{...props}
className={cn("mb-2 flex items-center justify-between gap-1", className)}
>
<div className="flex min-w-0 items-center gap-0.5">
<button
ref={monthTrigger}
type="button"
aria-label="Choose month"
aria-expanded={view === "months"}
aria-controls={view === "months" ? choicesId : undefined}
disabled={disabled}
onClick={() => openChoices("months")}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-[13px] font-semibold transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40",
view === "years" && "hidden",
)}
>
{monthNameFormat.format(parse(month))}
<ChevronDown
size={12}
aria-hidden="true"
className={cn(
"text-muted-foreground",
view === "months" && "rotate-180",
)}
/>
</button>
<button
ref={yearTrigger}
type="button"
aria-label="Choose year"
aria-expanded={view === "years"}
aria-controls={view === "years" ? choicesId : undefined}
disabled={disabled}
onClick={() => openChoices("years")}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-[13px] font-medium tabular-nums text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
>
{view === "years" ? `${yearPage}–${yearPageEnd}` : year}
<ChevronDown
size={12}
aria-hidden="true"
className={cn(view === "years" && "rotate-180")}
/>
</button>
</div>
<div className="flex shrink-0 gap-0.5">
<button
type="button"
aria-label={
view === "years"
? "Previous years"
: view === "months"
? "Previous year"
: "Previous month"
}
disabled={previousDisabled}
className={iconClass}
onClick={() => page(-1)}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<button
type="button"
aria-label={
view === "years"
? "Next years"
: view === "months"
? "Next year"
: "Next month"
}
disabled={nextDisabled}
hidden={view === "years" && nextDisabled}
className={cn(
iconClass,
view === "years" && nextDisabled && "hidden",
)}
onClick={() => page(1)}
>
<ChevronRight size={16} aria-hidden="true" />
</button>
</div>
</div>
);
}
export type DateRangePickerGridProps = Omit<
ComponentPropsWithoutRef<"div">,
"children"
>;
export function DateRangePickerGrid({
className,
onPointerLeave,
...props
}: DateRangePickerGridProps) {
const {
setPreview,
view,
month,
yearPage,
yearPageEnd,
year,
direction,
reduce,
choicesId,
choices,
focusChoices,
closeChoices,
goToMonth,
grid,
heading,
help,
active,
locale,
weeks,
shownRange,
value,
canHover,
unavailable,
dateFormat,
today,
select,
setCursor,
onKey,
} = useDateRangePickerContext("DateRangePickerGrid");
return (
<div
{...props}
className={cn("overflow-hidden", className)}
onPointerLeave={(event) => {
onPointerLeave?.(event);
setPreview(null);
}}
>
<AnimatePresence
initial={false}
mode="popLayout"
custom={{ direction, reduce }}
>
<motion.div
key={
view === "days"
? month
: `${view}-${view === "years" ? yearPage : year}`
}
custom={{ direction, reduce }}
variants={monthVariants}
initial="enter"
animate={{ opacity: 1, x: 0 }}
exit="exit"
transition={{ duration: reduce ? 0.1 : 0.18, ease: EASE_OUT }}
>
<CalendarPresence>
{view !== "days" ? (
<CalendarChoices
id={choicesId}
label={
view === "months"
? `Choose a month in ${year}`
: `Choose a year from ${yearPage} to ${yearPageEnd}`
}
choices={choices}
selected={view === "months" ? month : String(year)}
focusOnMount={focusChoices}
reduce={reduce}
onClose={closeChoices}
onSelect={(id) =>
goToMonth(
view === "months"
? id
: `${id.padStart(4, "0")}-${month.slice(5, 7)}-01`,
)
}
/>
) : (
<table
ref={(node) => {
if (node) {
grid.current = node;
return () => {
if (grid.current === node) grid.current = null;
};
}
}}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: WAI calendar grids retain native table structure with an interactive grid role.
role="grid"
aria-labelledby={heading}
aria-describedby={help}
tabIndex={active === null ? 0 : undefined}
className="w-full table-fixed select-none border-separate border-spacing-x-0 border-spacing-y-1"
>
<thead>
<tr>
{Array.from({ length: 7 }, (_, index) => {
const day = new Intl.DateTimeFormat(locale, {
weekday: "long",
timeZone: "UTC",
}).format(Date.UTC(2026, 0, 4 + index));
return (
<th
key={day}
scope="col"
abbr={day}
className="h-6 pb-1.5 text-center text-[11px] font-medium text-muted-foreground"
>
{new Intl.DateTimeFormat(locale, {
weekday: "short",
timeZone: "UTC",
}).format(Date.UTC(2026, 0, 4 + index))}
</th>
);
})}
</tr>
</thead>
<tbody>
{weeks.map(({ start, dates: week }) => (
<tr key={start}>
{week.map((date, dayIndex) => {
const inRange = Boolean(
date &&
shownRange &&
date >= shownRange.from &&
date <= (shownRange.to ?? shownRange.from),
);
const endpoint = Boolean(
date &&
value &&
(date === value.from || date === value.to),
);
return (
// biome-ignore lint/a11y/useFocusableInteractive: Calendar cells delegate focus to the contained day button.
<td
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: Gridcell selection semantics belong on the native table cell.
role="gridcell"
key={date ?? `empty-${dayIndex}`}
aria-selected={
date
? Boolean(
value &&
date >= value.from &&
date <= (value.to ?? value.from),
)
: undefined
}
onPointerEnter={(event) => {
if (
canHover &&
event.pointerType !== "touch" &&
date &&
!unavailable(date) &&
value &&
!value.to
)
setPreview(date);
}}
className={cn(
"relative h-8 p-0 text-center",
inRange &&
(dayIndex === 0 || date === shownRange?.from) &&
"rounded-l-full",
inRange &&
(dayIndex === 6 ||
date === shownRange?.to ||
!shownRange?.to) &&
"rounded-r-full",
)}
>
{date && (
<span
aria-hidden="true"
// Range scrubbing follows immediately: independent cell
// fades leave stale highlights behind a moving pointer.
className={cn(
"pointer-events-none absolute inset-y-0 rounded-[inherit] bg-foreground/[0.08]",
inRange ? "opacity-100" : "opacity-0",
)}
style={{
left:
endpoint &&
(date === shownRange?.from ||
!week[dayIndex - 1])
? "50%"
: 0,
right:
endpoint &&
(date ===
(shownRange?.to ?? shownRange?.from) ||
!week[dayIndex + 1])
? "50%"
: 0,
}}
/>
)}
{date && (
<motion.button
type="button"
disabled={unavailable(date)}
aria-label={dateFormat.format(parse(date))}
aria-current={
date === today ? "date" : undefined
}
tabIndex={date === active ? 0 : -1}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
onClick={() => select(date)}
onFocus={() => {
setCursor(date);
if (value && !value.to) setPreview(date);
}}
onKeyDown={(event) => onKey(event, date)}
className={cn(
"relative mx-auto flex h-8 w-full max-w-8 items-center justify-center rounded-full text-xs tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-30",
endpoint
? "bg-foreground font-medium text-background"
: canHover &&
!unavailable(date) &&
!inRange &&
"hover:bg-muted",
date === today &&
!endpoint &&
"font-semibold after:absolute after:bottom-1 after:size-0.75 after:rounded-full after:bg-current",
)}
>
{new Date(parse(date)).getUTCDate()}
</motion.button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
)}
</CalendarPresence>
</motion.div>
</AnimatePresence>
</div>
);
}
export type DateRangePickerSelectionProps = ComponentPropsWithoutRef<"p">;
export function DateRangePickerSelection({
children,
className,
...props
}: DateRangePickerSelectionProps) {
const { value, dayCount } = useDateRangePickerContext(
"DateRangePickerSelection",
);
return (
<p {...props} className={cn("text-xs text-muted-foreground", className)}>
{children ??
(dayCount
? `${dayCount} ${dayCount === 1 ? "day" : "days"} selected`
: value
? "Choose an end date"
: "Choose a start date")}
</p>
);
}
export type DateRangePickerClearProps = ComponentPropsWithRef<"button">;
export function DateRangePickerClear({
children = "Clear",
className,
onClick,
disabled,
...props
}: DateRangePickerClearProps) {
const ctx = useDateRangePickerContext("DateRangePickerClear");
return (
<button
{...props}
type="button"
aria-label={props["aria-label"] ?? "Clear date range"}
disabled={disabled || ctx.disabled || !ctx.value}
className={cn(
"rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-30",
className,
)}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) ctx.clear();
}}
>
{children}
</button>
);
}
export type DateRangePickerFooterProps = ComponentPropsWithoutRef<"div">;
export function DateRangePickerFooter({
children,
className,
...props
}: DateRangePickerFooterProps) {
return (
<div
{...props}
className={cn("mt-2.5 border-t border-border pt-2.5", className)}
>
{children === undefined ? (
<>
<div className="flex items-center justify-between gap-2">
<DateRangePickerSelection />
<DateRangePickerClear />
</div>
<DateRangePickerPresets />
</>
) : (
children
)}
</div>
);
}
export type DateRangePickerPresetsProps = ComponentPropsWithoutRef<"div">;
export function DateRangePickerPresets({
children,
className,
...props
}: DateRangePickerPresetsProps) {
const ctx = useDateRangePickerContext("DateRangePickerPresets");
if (children === undefined && ctx.presets.length === 0) return null;
return (
<div
{...props}
className={cn("mt-2.5 flex flex-wrap justify-center gap-1.5", className)}
>
{children === undefined
? ctx.presets.map((preset) => (
<DateRangePickerPreset
key={preset.label}
value={preset.value}
label={preset.label}
>
{preset.label}
</DateRangePickerPreset>
))
: children}
</div>
);
}
export interface DateRangePickerPresetProps
extends Omit<ComponentPropsWithRef<"button">, "value"> {
value: DateRange;
/** Accessible preset name and selection announcement. */
label: string;
}
export function DateRangePickerPreset({
value,
label,
children,
className,
disabled,
onClick,
...props
}: DateRangePickerPresetProps) {
const ctx = useDateRangePickerContext("DateRangePickerPreset");
const selected = ctx.value?.from === value.from && ctx.value?.to === value.to;
return (
<button
{...props}
type="button"
aria-label={label}
aria-pressed={selected}
disabled={disabled || !ctx.rangeAvailable(value)}
className={cn(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40",
selected
? "bg-foreground text-background"
: "bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground",
className,
)}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) ctx.selectPreset(value, label);
}}
>
{children ?? label}
</button>
);
}
export interface DateRangePickerTriggerProps
extends HTMLMotionProps<"button"> {
placeholder?: string;
}
export function DateRangePickerTrigger({
children,
placeholder = "Select dates",
className,
disabled,
ref,
...props
}: DateRangePickerTriggerProps) {
const ctx = useDateRangePickerContext("DateRangePickerTrigger");
const mergedRef = useCallback(
(node: HTMLButtonElement | null) => {
ctx.trigger.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ctx.trigger, ref],
);
const format = new Intl.DateTimeFormat(ctx.locale, {
month: "short",
day: "numeric",
timeZone: "UTC",
});
const sameYear =
ctx.value?.to && ctx.value.from.slice(0, 4) === ctx.value.to.slice(0, 4);
const summary = ctx.value
? `${(sameYear ? format : ctx.shortFormat).format(parse(ctx.value.from))} – ${ctx.value.to ? ctx.shortFormat.format(parse(ctx.value.to)) : "Select end"}`
: placeholder;
return (
<MorphPopoverTrigger>
<motion.button
{...props}
ref={mergedRef}
layout={!ctx.reduce}
transition={{ layout: SPRING_LAYOUT }}
style={{ borderRadius: 12, ...props.style }}
type="button"
disabled={disabled || ctx.disabled}
aria-label={props["aria-label"] ?? `${ctx.label}: ${summary}`}
className={cn(
"inline-flex h-9 w-fit max-w-full items-center gap-2 rounded-xl border border-border bg-background px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40",
className,
)}
>
{children != null ? (
<motion.span
layout={ctx.reduce ? false : "position"}
transition={{ layout: SPRING_LAYOUT }}
className="inline-flex min-w-0 items-center gap-2"
>
{children}
</motion.span>
) : (
<>
<motion.span
layout={ctx.reduce ? false : "position"}
transition={{ layout: SPRING_LAYOUT }}
className="shrink-0 text-muted-foreground"
aria-hidden="true"
>
<CalendarDays size={15} />
</motion.span>
<motion.span
layout={ctx.reduce ? false : "position"}
transition={{ layout: SPRING_LAYOUT }}
className="min-w-0 truncate tabular-nums"
>
{summary}
</motion.span>
<motion.span
layout={ctx.reduce ? false : "position"}
transition={{ layout: SPRING_LAYOUT }}
className="shrink-0 text-muted-foreground"
aria-hidden="true"
>
<ChevronDown size={13} className={cn(ctx.open && "rotate-180")} />
</motion.span>
</>
)}
</motion.button>
</MorphPopoverTrigger>
);
}
export interface DateRangePickerContentProps extends MorphPopoverContentProps {
/** Move focus to the active calendar date when opened. Default true. */
autoFocus?: boolean;
}
function focusCalendarDate(content: HTMLDivElement | null) {
const grid = Array.from(
content?.querySelectorAll<HTMLElement>('[role="grid"]') ?? [],
).find((element) => !element.closest("[inert]"));
const target =
grid?.querySelector<HTMLElement>('button[tabindex="0"]') ?? grid;
target?.focus({ preventScroll: true });
}
export function DateRangePickerContent({
children,
className,
autoFocus = true,
onOpenAutoFocus,
...props
}: DateRangePickerContentProps) {
const ctx = useDateRangePickerContext("DateRangePickerContent");
useLayoutEffect(() => {
// A rapid reopen can revive the exiting surface before it unmounts.
// That surface already ran its positioned callback, so focus it here too.
if (ctx.open && autoFocus) focusCalendarDate(ctx.content.current);
}, [ctx.open, ctx.content, autoFocus]);
return (
<MorphPopoverContent
align="start"
radius={16}
shadow={false}
{...props}
className={cn("w-80 max-w-[calc(100vw-1.5rem)]", className)}
onOpenAutoFocus={(content) => {
ctx.content.current = content;
if (autoFocus) focusCalendarDate(content);
onOpenAutoFocus?.(content);
}}
>
{children}
</MorphPopoverContent>
);
}
export interface DateRangePickerDropdownProps
extends Omit<DateRangePickerProps, "children"> {
placeholder?: string;
triggerClassName?: string;
calendarClassName?: string;
}
/** Convenience composition of the root, trigger, content and calendar. */
export function DateRangePickerDropdown({
placeholder,
triggerClassName,
calendarClassName,
autoFocus = true,
showSummary = false,
...props
}: DateRangePickerDropdownProps) {
return (
<DateRangePicker {...props} autoFocus={autoFocus} showSummary={showSummary}>
<DateRangePickerTrigger
placeholder={placeholder}
className={triggerClassName}
/>
<DateRangePickerContent autoFocus={autoFocus}>
<DateRangePickerCalendar
className={cn("w-full rounded-none border-0", calendarClassName)}
/>
</DateRangePickerContent>
</DateRangePicker>
);
}
// The departing month's grid must leave the tab order as soon as exit begins.
function CalendarPresence({ children }: { children: ReactNode }) {
const present = useIsPresent();
return <div inert={!present}>{children}</div>;
}
function CalendarChoices({
id,
label,
choices,
selected,
focusOnMount,
reduce,
onClose,
onSelect,
}: {
id: string;
label: string;
choices: { id: string; label: string; disabled: boolean }[];
selected: string;
focusOnMount: boolean;
reduce: boolean;
onClose: () => void;
onSelect: (id: string) => void;
}) {
const enabled = choices.filter((choice) => !choice.disabled);
const [cursor, setCursor] = useState<string | null>(selected);
const active =
enabled.find((choice) => choice.id === cursor)?.id ??
enabled.find((choice) => choice.id === selected)?.id ??
enabled[0]?.id;
if (cursor !== null && cursor !== active) setCursor(active ?? null);
const root = useRef<HTMLFieldSetElement>(null);
useLayoutEffect(() => {
if (focusOnMount)
root.current
?.querySelector<HTMLButtonElement>('button[tabindex="0"]')
?.focus({ preventScroll: true });
}, [focusOnMount]);
const onKey = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const buttons = Array.from(
root.current?.querySelectorAll<HTMLButtonElement>("button") ?? [],
);
const index = buttons.indexOf(event.currentTarget);
const offset =
event.key === "ArrowLeft"
? -1
: event.key === "ArrowRight"
? 1
: event.key === "ArrowUp"
? -3
: event.key === "ArrowDown"
? 3
: null;
let target =
event.key === "Home"
? 0
: event.key === "End"
? buttons.length - 1
: offset === null
? null
: index + offset;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
return;
}
if (target === null) return;
event.preventDefault();
const step =
offset === null ? (event.key === "Home" ? 1 : -1) : Math.sign(offset);
while (target >= 0 && target < buttons.length && buttons[target].disabled)
target += step;
buttons[target]?.focus({ preventScroll: true });
};
return (
<fieldset
ref={root}
id={id}
aria-label={label}
className="grid min-h-[15.25rem] grid-cols-3 content-start gap-2 border-0 p-0 pt-1"
>
{choices.map((choice) => (
<motion.button
key={choice.id}
type="button"
disabled={choice.disabled}
aria-pressed={choice.id === selected}
tabIndex={choice.id === active ? 0 : -1}
onFocus={() => setCursor(choice.id)}
onKeyDown={onKey}
onClick={() => onSelect(choice.id)}
whileTap={reduce ? undefined : { scale: 0.96 }}
transition={SPRING_PRESS}
className={cn(
"flex h-13 items-center justify-center rounded-xl px-1 text-[13px] tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-25",
choice.id === selected
? "bg-foreground font-medium text-background"
: "bg-muted/40 hover:bg-muted",
)}
>
{choice.label}
</motion.button>
))}
</fieldset>
);
}
"use client";
import { createContext, useContext } from "react";
import type { useDateRangePickerController } from "./use-date-range-picker";
type DateRangePickerContextValue = ReturnType<
typeof useDateRangePickerController
>;
export const DateRangePickerContext =
createContext<DateRangePickerContextValue | null>(null);
export function useDateRangePickerContext(part: string) {
const context = useContext(DateRangePickerContext);
if (!context) throw new Error(`${part} must be used within DateRangePicker`);
return context;
}
import type { DateRange } from "./types";
export const DAY = 86_400_000;
export function parse(date: string) {
const time = Date.parse(`${date}T00:00:00Z`);
if (
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
!Number.isFinite(time) ||
new Date(time).toISOString().slice(0, 10) !== date
)
throw new Error(`Invalid calendar date: ${date}. Use YYYY-MM-DD.`);
return time;
}
export function iso(time: number) {
return new Date(time).toISOString().slice(0, 10);
}
export function monthOf(date: string) {
return `${date.slice(0, 7)}-01`;
}
export function shiftMonth(date: string, offset: number) {
const day = new Date(parse(date));
const month = new Date(parse(monthOf(date)));
month.setUTCMonth(month.getUTCMonth() + offset);
const end = new Date(month);
end.setUTCMonth(end.getUTCMonth() + 1);
end.setUTCDate(0);
month.setUTCDate(Math.min(day.getUTCDate(), end.getUTCDate()));
return iso(month.getTime());
}
export function ordered(a: string, b: string): Required<DateRange> {
return a <= b ? { from: a, to: b } : { from: b, to: a };
}
import type { ReactNode } from "react";
/** Calendar dates, independent of a browser's timezone: YYYY-MM-DD. */
export interface DateRange {
from: string;
to?: string;
}
export interface DateRangePreset {
label: string;
value: DateRange;
}
export interface DateRangePickerProps {
value?: DateRange | null;
defaultValue?: DateRange | null;
onValueChange?: (range: DateRange | null) => void;
defaultMonth?: string;
min?: string;
max?: string;
isDateDisabled?: (date: string) => boolean;
presets?: DateRangePreset[];
locale?: string;
label?: string;
disabled?: boolean;
/** Move focus to the active date on mount, for use inside a popover. */
autoFocus?: boolean;
/** Show the start/end summary above the calendar. Default true. */
showSummary?: boolean;
className?: string;
/** Compose custom controls; omitted children render the inline calendar. */
children?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Close an open popover after a complete range or preset. Default false. */
closeOnSelect?: boolean;
}
"use client";
import { useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { DAY, parse, iso, monthOf, shiftMonth, ordered } from "./date-utils";
import type { DateRange, DateRangePickerProps } from "./types";
export function useDateRangePickerController({
value: controlledValue,
defaultValue = null,
onValueChange,
defaultMonth,
min,
max,
isDateDisabled,
presets = [],
locale = "en-US",
label = "Choose a date range",
disabled = false,
autoFocus = false,
showSummary = true,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
closeOnSelect = false,
}: DateRangePickerProps) {
const [today] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
});
const [internalValue, setInternalValue] = useState(defaultValue);
const value = controlledValue === undefined ? internalValue : controlledValue;
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = controlledOpen === undefined ? internalOpen : controlledOpen;
const trigger = useRef<HTMLButtonElement>(null);
const content = useRef<HTMLDivElement>(null);
const setOpen = (next: boolean) => {
if (controlledOpen === undefined) setInternalOpen(next);
onOpenChange?.(next);
if (!next && content.current?.contains(document.activeElement))
trigger.current?.focus({ preventScroll: true });
};
useLayoutEffect(() => {
if (!open && content.current?.contains(document.activeElement))
trigger.current?.focus({ preventScroll: true });
}, [open]);
if (min) parse(min);
if (max) parse(max);
if (min && max && min > max)
throw new Error("DateRangePicker min must not exceed max.");
if (defaultMonth) parse(defaultMonth);
if (value) {
parse(value.from);
if (value.to) {
parse(value.to);
if (value.to < value.from)
throw new Error("DateRangePicker range must be ordered.");
}
}
const initial =
defaultMonth ??
value?.from ??
(min && today < min ? min : max && today > max ? max : today);
const [requestedMonth, setMonth] = useState(() => monthOf(initial));
const month =
min && requestedMonth < monthOf(min)
? monthOf(min)
: max && requestedMonth > monthOf(max)
? monthOf(max)
: requestedMonth;
if (requestedMonth !== month) setMonth(month);
const [cursor, setCursor] = useState<string | null>(
() => value?.from ?? initial,
);
const [preview, setPreview] = useState<string | null>(null);
const [direction, setDirection] = useState(1);
const [announcement, setAnnouncement] = useState("");
const [view, setView] = useState<"days" | "months" | "years">("days");
const [requestedYearPage, setYearPage] = useState(
() => Math.floor(Number(initial.slice(0, 4)) / 12) * 12,
);
const [focusChoices, setFocusChoices] = useState(false);
const monthTrigger = useRef<HTMLButtonElement>(null);
const yearTrigger = useRef<HTMLButtonElement>(null);
const returnToTrigger = useRef<"months" | "years" | null>(null);
const grid = useRef<HTMLTableElement>(null);
const pendingFocus = useRef(autoFocus);
const heading = useId();
const help = useId();
const choicesId = useId();
const reduce = useReducedMotion() ?? false;
const canHover = useHoverCapable();
const monthFormat = new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
timeZone: "UTC",
});
const monthNameFormat = new Intl.DateTimeFormat(locale, {
month: "long",
timeZone: "UTC",
});
const dateFormat = new Intl.DateTimeFormat(locale, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
const shortFormat = new Intl.DateTimeFormat(locale, {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
const unavailable = (date: string) =>
disabled ||
Boolean(min && date < min) ||
Boolean(max && date > max) ||
Boolean(isDateDisabled?.(date));
const rangeAvailable = (range: DateRange) => {
const first = parse(range.from);
const last = parse(range.to ?? range.from);
if (
last < first ||
unavailable(range.from) ||
unavailable(range.to ?? range.from)
)
return false;
// Only scan interiors when the consumer supplies unavailable dates.
if (isDateDisabled)
for (let time = first + DAY; time < last; time += DAY)
if (unavailable(iso(time))) return false;
return true;
};
const lastDay = new Date(parse(month));
lastDay.setUTCMonth(lastDay.getUTCMonth() + 1);
lastDay.setUTCDate(0);
const daysInMonth = lastDay.getUTCDate();
const dates = Array.from({ length: daysInMonth }, (_, index) =>
iso(parse(month) + index * DAY),
);
const active =
cursor && dates.includes(cursor) && !unavailable(cursor)
? cursor
: (dates.find((date) => !unavailable(date)) ?? null);
if (cursor !== null && cursor !== active) setCursor(active);
const shownRange =
value &&
!value.to &&
preview &&
rangeAvailable(ordered(value.from, preview))
? ordered(value.from, preview)
: value;
const latest = useRef({ month, active });
useLayoutEffect(() => {
latest.current = { month, active };
});
useLayoutEffect(() => {
if (returnToTrigger.current) {
const target = returnToTrigger.current;
returnToTrigger.current = null;
(target === "months" ? monthTrigger : yearTrigger).current?.focus({
preventScroll: true,
});
}
if (!pendingFocus.current) return;
pendingFocus.current = false;
const button = grid.current?.querySelector<HTMLButtonElement>(
'button[tabindex="0"]',
);
(button ?? grid.current)?.focus({ preventScroll: true });
});
const change = (next: DateRange | null) => {
if (disabled || (next && !rangeAvailable(next))) return;
if (controlledValue === undefined) setInternalValue(next);
onValueChange?.(next);
setPreview(null);
if (next?.to && open && closeOnSelect) setOpen(false);
};
const select = (date: string) => {
if (unavailable(date)) return;
setCursor(date);
if (!value || value.to) {
change({ from: date });
setAnnouncement(
`Start date ${dateFormat.format(parse(date))}. Choose an end date.`,
);
} else {
const next = ordered(value.from, date);
if (!rangeAvailable(next)) {
setAnnouncement(
"This range includes unavailable dates. Choose another end date.",
);
return;
}
change(next);
setAnnouncement(
`${shortFormat.format(parse(next.from))} to ${shortFormat.format(parse(next.to))} selected.`,
);
}
};
const navigate = (
offset: number,
focus = false,
target = latest.current.active ?? latest.current.month,
) => {
const next = shiftMonth(target, offset);
if (
(min && monthOf(next) < monthOf(min)) ||
(max && monthOf(next) > monthOf(max))
)
return;
latest.current = { month: monthOf(next), active: next };
setDirection(offset > 0 ? 1 : -1);
setMonth(monthOf(next));
setCursor(next);
setPreview(null);
pendingFocus.current = focus;
};
const onKey = (event: KeyboardEvent<HTMLButtonElement>, date: string) => {
if (event.altKey || event.ctrlKey || event.metaKey) return;
if (event.key === "PageUp" || event.key === "PageDown") {
event.preventDefault();
navigate(
(event.key === "PageUp" ? -1 : 1) * (event.shiftKey ? 12 : 1),
true,
date,
);
return;
}
const base = latest.current.active ?? date;
const weekday = new Date(parse(base)).getUTCDay();
const offset =
event.key === "ArrowLeft"
? -1
: event.key === "ArrowRight"
? 1
: event.key === "ArrowUp"
? -7
: event.key === "ArrowDown"
? 7
: event.key === "Home"
? -weekday
: event.key === "End"
? 6 - weekday
: null;
if (offset === null) return;
event.preventDefault();
let next = iso(parse(base) + offset * DAY);
// Arrow navigation skips unavailable dates; Home/End stay within their week.
if (event.key !== "Home" && event.key !== "End") {
const step = offset < 0 ? -1 : 1;
for (let attempts = 0; unavailable(next) && attempts < 366; attempts++) {
if ((min && next < min) || (max && next > max)) return;
next = iso(parse(next) + step * DAY);
}
}
if (unavailable(next)) return;
const nextMonth = monthOf(next);
setDirection(nextMonth >= latest.current.month ? 1 : -1);
// Own event updates are visible to a second key in the same batch.
latest.current = { month: nextMonth, active: next };
pendingFocus.current = true;
setMonth(nextMonth);
setCursor(next);
setPreview(value && !value.to ? next : null);
};
const leading = new Date(parse(month)).getUTCDay();
const cells: (string | null)[] = [
...Array<string | null>(leading).fill(null),
...dates,
];
// Six rows keep the surface stable while changing months or browsing years.
while (cells.length < 42) cells.push(null);
const weeks = Array.from({ length: cells.length / 7 }, (_, index) => ({
start: iso(parse(month) + (index * 7 - leading) * DAY),
dates: cells.slice(index * 7, index * 7 + 7),
}));
const year = Number(month.slice(0, 4));
const monthIndex = Number(month.slice(5, 7)) - 1;
const minYear = min ? Number(min.slice(0, 4)) : 0;
const maxYear = max ? Number(max.slice(0, 4)) : 9999;
const firstYearPage = minYear;
// Backfill the final page so it still has twelve years when the bounds allow it.
const lastYearPage = Math.max(minYear, maxYear - 11);
const yearPage = Math.min(
lastYearPage,
Math.max(firstYearPage, requestedYearPage),
);
if (requestedYearPage !== yearPage) setYearPage(yearPage);
const yearPageEnd = Math.min(yearPage + 11, maxYear);
const previousDisabled =
disabled ||
(view === "years"
? yearPage <= firstYearPage
: view === "months"
? year <= minYear
: (year === 0 && monthIndex === 0) ||
Boolean(min && monthOf(shiftMonth(month, -1)) < monthOf(min)));
const nextDisabled =
disabled ||
(view === "years"
? yearPage >= lastYearPage
: view === "months"
? year >= maxYear
: (year === 9999 && monthIndex === 11) ||
Boolean(max && monthOf(shiftMonth(month, 1)) > monthOf(max)));
const closeChoices = () => {
returnToTrigger.current = view === "years" ? "years" : "months";
setView("days");
};
const openChoices = (next: "months" | "years") => {
if (disabled) return;
if (view === next) {
closeChoices();
return;
}
setPreview(null);
setFocusChoices(true);
if (next === "years")
setYearPage(
Math.min(
lastYearPage,
Math.max(
firstYearPage,
minYear + Math.floor((year - minYear) / 12) * 12,
),
),
);
setView(next);
};
const goToMonth = (next: string) => {
if (disabled) return;
const clamped =
min && next < monthOf(min)
? monthOf(min)
: max && next > monthOf(max)
? monthOf(max)
: next;
setDirection(clamped >= month ? 1 : -1);
setMonth(clamped);
setCursor(clamped);
setPreview(null);
closeChoices();
};
const page = (step: -1 | 1) => {
setFocusChoices(false);
if (view === "years") {
setDirection(step);
const next = Math.min(
lastYearPage,
Math.max(firstYearPage, yearPage + step * 12),
);
if (next === firstYearPage || next === lastYearPage)
returnToTrigger.current = "years";
setYearPage((current) =>
Math.min(lastYearPage, Math.max(firstYearPage, current + step * 12)),
);
} else if (view === "months") {
const next = shiftMonth(month, step * 12);
const clamped =
min && next < monthOf(min)
? monthOf(min)
: max && next > monthOf(max)
? monthOf(max)
: monthOf(next);
setDirection(step);
setMonth(clamped);
setCursor(clamped);
if (
Number(clamped.slice(0, 4)) === minYear ||
Number(clamped.slice(0, 4)) === maxYear
)
returnToTrigger.current = "months";
} else {
const next = monthOf(shiftMonth(latest.current.active ?? month, step));
if (next === (min && monthOf(min)) || next === (max && monthOf(max)))
returnToTrigger.current = "months";
navigate(step);
}
};
const choices =
view === "months"
? Array.from({ length: 12 }, (_, index) => {
const date = `${String(year).padStart(4, "0")}-${String(index + 1).padStart(2, "0")}-01`;
return {
id: date,
label: monthNameFormat.format(parse(date)),
disabled:
disabled ||
Boolean(min && date < monthOf(min)) ||
Boolean(max && date > monthOf(max)),
};
})
: Array.from(
{ length: Math.max(0, yearPageEnd - yearPage + 1) },
(_, index) => {
const choiceYear = yearPage + index;
return {
id: String(choiceYear),
label: String(choiceYear),
disabled:
disabled || choiceYear < minYear || choiceYear > maxYear,
};
},
);
const dayCount = value?.to
? Math.round((parse(value.to) - parse(value.from)) / DAY) + 1
: null;
const clear = () => {
if (disabled) return;
if (view === "days") pendingFocus.current = true;
else returnToTrigger.current = view;
change(null);
setAnnouncement("Date range cleared.");
};
const selectPreset = (range: DateRange, name: string) => {
if (!rangeAvailable(range)) return;
if (view !== "days") closeChoices();
change(range);
setDirection(range.from >= month ? 1 : -1);
setMonth(monthOf(range.from));
setCursor(range.from);
setAnnouncement(`${name} selected.`);
};
const [wasOpen, setWasOpen] = useState(open);
if (wasOpen !== open) {
setWasOpen(open);
if (open) {
// A reopened popover starts a fresh calendar session, retaining its range.
setMonth(monthOf(initial));
setCursor(value?.from ?? initial);
setPreview(null);
setView("days");
setFocusChoices(false);
setAnnouncement("");
}
}
return {
value,
change,
open,
setOpen,
trigger,
content,
label,
locale,
disabled,
autoFocus,
showSummary,
presets,
today,
month,
view,
heading,
help,
choicesId,
monthTrigger,
yearTrigger,
monthFormat,
monthNameFormat,
shortFormat,
dateFormat,
year,
yearPage,
yearPageEnd,
previousDisabled,
nextDisabled,
openChoices,
page,
direction,
reduce,
choices,
focusChoices,
closeChoices,
goToMonth,
grid,
active,
weeks,
shownRange,
canHover,
unavailable,
setPreview,
select,
setCursor,
onKey,
dayCount,
clear,
rangeAvailable,
selectPreset,
announcement,
};
}
"use client";
import {
AnimatePresence,
motion,
animate,
useMotionValue,
usePresence,
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");
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> } | undefined)
?.ref;
// Register once per actual ref change, not once per open-state render.
const mergedRef = useMemo(
() => mergeRefs(childRef, ctx.registerTrigger),
[childRef, ctx.registerTrigger],
);
if (!isValidElement(children)) return children;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergedRef,
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 clipAt(side: Side, align: Align, radius: number, inset: number) {
const top = side === "bottom" ? "0%" : `${inset}%`;
const bottom = side === "bottom" ? `${inset}%` : "0%";
const right = align === "end" ? "0%" : `${inset}%`;
const left = align === "end" ? `${inset}%` : "0%";
return `inset(${top} ${right} ${bottom} ${left} 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;
/** Draw the surface shadow. Default true. */
shadow?: boolean;
/** Runs after the portalled surface is positioned and visible. */
onOpenAutoFocus?: (content: HTMLDivElement) => void;
className?: string;
}
export function MorphPopoverContent(props: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const [portalReady, setPortalReady] = useState(false);
useEffect(() => setPortalReady(true), []);
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open && <MorphPopoverSurface {...props} />}
</AnimatePresence>,
document.body,
);
}
// Measurement belongs to the mounted portal session: reopening must not start
// an entrance at the previous session's coordinates before measuring this one.
function MorphPopoverSurface({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
shadow = true,
onOpenAutoFocus,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [isPresent, safeToRemove] = usePresence();
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
isPresent,
);
const preferredLeft = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
// Keep larger compositions, such as calendars, inside the viewport. Prefer
// the requested side, then use the roomier side when it cannot fit there.
const gutter = 12;
const below = layout
? Math.max(
0,
window.innerHeight -
layout.trigger.top -
layout.trigger.height -
sideOffset -
gutter,
)
: 0;
const above = layout
? Math.max(0, layout.trigger.top - sideOffset - gutter)
: 0;
const requestedSpace = side === "bottom" ? below : above;
const otherSpace = side === "bottom" ? above : below;
const resolvedSide =
layout &&
layout.content.height > requestedSpace &&
otherSpace > requestedSpace
? side === "bottom"
? "top"
: "bottom"
: side;
const availableHeight = resolvedSide === "bottom" ? below : above;
const left = layout
? Math.max(
gutter,
Math.min(
preferredLeft,
window.innerWidth - layout.content.width - gutter,
),
)
: 0;
const top = layout
? resolvedSide === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: Math.max(
gutter,
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: { scale: 0.96, transition: SPRING_PANEL },
show: { scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipAt(resolvedSide, align, radius, 92),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipAt(resolvedSide, align, radius, 0),
transition: MORPH_CLIP_TRANSITION,
},
};
// Animate the value directly so opacity stays in the inline style throughout
// the entrance. A native opacity animation can expose the initial inline 0
// for a frame when it finishes, before Motion writes the final value.
const opacity = useMotionValue(0);
const ready = layout !== null;
const focusedOnOpen = useRef(false);
useEffect(() => {
if (
!ready ||
!isPresent ||
focusedOnOpen.current ||
!ctx.contentRef.current
)
return;
focusedOnOpen.current = true;
onOpenAutoFocus?.(ctx.contentRef.current);
}, [ready, isPresent, ctx.contentRef, onOpenAutoFocus]);
useEffect(() => {
if (!ready) {
if (!isPresent) safeToRemove?.();
return;
}
const animation = animate(opacity, isPresent ? 1 : 0, {
...(reduce ? { duration: 0.12 } : SPRING_PANEL),
onComplete: () => {
if (!isPresent) safeToRemove?.();
},
});
return () => animation.stop();
}, [opacity, ready, isPresent, reduce, safeToRemove]);
return (
<motion.div
data-morph-popover-portal=""
inert={!isPresent}
// 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="hidden"
animate={layout ? "show" : "hidden"}
exit="hidden"
style={{
left,
top,
opacity,
pointerEvents: isPresent ? "auto" : "none",
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(resolvedSide, align),
}}
className={cn(
"fixed z-[9999]",
shadow && "[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,
maxHeight: layout ? availableHeight : undefined,
overflowY: "auto",
}}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.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
DateRangePicker
value?DateRange | null—defaultValue?DateRange | null—onValueChange?((range: DateRange | null) => void)—defaultMonth?string—min?string—max?string—isDateDisabled?((date: string) => boolean)—presets?DateRangePreset[]—locale?string—label?string—disabled?boolean—autoFocus?booleanMove focus to the active date on mount, for use inside a popover.
—showSummary?booleanShow the start/end summary above the calendar. Default true.
—className?string—children?ReactNodeCompose custom controls; omitted children render the inline calendar.
—open?boolean—defaultOpen?boolean—onOpenChange?((open: boolean) => void)—closeOnSelect?booleanClose an open popover after a complete range or preset. Default false.
—DateRangePickerCalendar
showSummary?boolean—className?string—DateRangePickerSummary
className?string—DateRangePickerHeader
className?string—DateRangePickerGrid
className?string—DateRangePickerSelection
className?string—DateRangePickerClear
className?string—DateRangePickerFooter
className?string—DateRangePickerPresets
className?string—DateRangePickerPreset
valueDateRange—labelstringAccessible preset name and selection announcement.
—className?string—DateRangePickerTrigger
placeholder?stringSelect datesclassName?string—DateRangePickerContent
autoFocus?booleanMove focus to the active calendar date when opened. Default true.
trueside?"bottom" | "top"—align?"start" | "end"—sideOffset?numberGap between trigger and panel, in px. Default 8.
—radius?numberPanel corner radius, in px. Default 16.
—shadow?booleanDraw the surface shadow. Default true.
—onOpenAutoFocus?((content: HTMLDivElement) => void)Runs after the portalled surface is positioned and visible.
—className?string—DateRangePickerDropdown
placeholder?string—triggerClassName?string—calendarClassName?string—defaultValue?DateRange | null—autoFocus?booleanMove focus to the active date on mount, for use inside a popover.
trueclassName?string—max?string—min?string—label?string—value?DateRange | null—disabled?boolean—onValueChange?((range: DateRange | null) => void)—open?boolean—closeOnSelect?booleanClose an open popover after a complete range or preset. Default false.
—defaultMonth?string—isDateDisabled?((date: string) => boolean)—presets?DateRangePreset[]—locale?string—showSummary?booleanShow the start/end summary above the calendar. Default true.
falsedefaultOpen?boolean—onOpenChange?((open: boolean) => void)—Related components
Sortable List
Composable sortable list with group, item, drag handle, content and undo parts, plus a shared state hook. Spring layout, keyboard reordering, accessible position announcements, and controlled or uncontrolled items.
Animated Color Selector
Composable color swatches with a spring-gliding selection ring, press feedback, native radio keyboard navigation, and controlled or uncontrolled state.
Popover
Gooey popover whose panel oozes out of the trigger through an SVG goo filter — a liquid neck that stretches and pinches — with crisp content fading in on top, plus a Morph variant that clip-morphs open from the trigger corner. Click or hover trigger, controlled or uncontrolled.
Updated