Component
Date range picker
Picks a start and end date from a calendar, with quick presets.
- #arc
- #component
- #input
- #date
- #range
- #calendar
- #preset
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/date-range-picker.jsonFiles
components/arc/date-range-picker/date-range-picker.module.csscomponents/arc/date-range-picker/date-range-picker.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdowndate-range-picker.module.cssdate-range-picker.module.cssdate-range-picker.module.csscss
/* The root holds the trigger's place in the page. The surface floats above it and grows into the panel, so opening never pushes content. */
.root {
position: relative;
display: inline-block;
height: var(--control-height-md);
vertical-align: top;
color: var(--foreground);
font-family: var(--font-body);
font-size: var(--text-sm);
letter-spacing: var(--tracking-body);
line-height: var(--leading-body);
}
.root { z-index: 0; transition: z-index 0s linear .5s; }
.root[data-open] { z-index: 30; transition-delay: 0s; }
.root:not([data-open]) .surface { z-index: 0; }
.root *, .root *::before, .root *::after { box-sizing: border-box; }
/* One material from trigger to panel. Size, corners, and offset come from springs; the border is an overlay so it never changes a measurement. */
.surface {
position: absolute;
z-index: 2;
top: 0;
left: 0;
overflow: clip;
background: var(--surface-raised);
box-shadow: var(--shadow-resting);
transition: box-shadow var(--duration-considered) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);
}
.surface::after { content: ""; position: absolute; inset: 0; border: 1px solid var(--border); border-radius: inherit; pointer-events: none; transition: border-color var(--duration-fast) var(--ease-standard); }
.root[data-open] .surface { box-shadow: var(--shadow-floating); }
/* Trigger */
/* The trigger sits in flow above the surface while closed; when open, the surface and its panel rise over it. */
.trigger {
position: relative;
z-index: 1;
display: inline-flex;
width: max-content;
height: var(--control-height-md);
align-items: center;
gap: 10px;
padding: 0 12px 0 14px;
border: 0;
background: transparent;
color: var(--foreground);
cursor: pointer;
font: inherit;
font-weight: 500;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
}
.trigger:disabled { cursor: default; }
.trigger[inert] { pointer-events: none; }
.triggerIcon, .chevron { display: grid; flex: none; place-items: center; color: var(--text-secondary); }
.chevron { color: var(--text-muted); }
.value { display: inline-flex; font-variant-numeric: tabular-nums; }
.value[data-empty] { color: var(--text-secondary); font-weight: 400; }
/* Words roll in their own slots; a leaving word pops out of flow so the next one never waits for space. */
.rolling { display: inline-flex; gap: .28em; white-space: nowrap; }
.word { position: relative; display: inline-block; }
.wordInner { display: inline-block; }
/* Panel */
.panel { position: absolute; top: 0; left: 0; width: max-content; padding: 10px; --cell: 36px; }
.body { display: flex; gap: 10px; }
.rail { position: relative; display: flex; width: 9.25rem; flex: none; flex-direction: column; gap: 2px; padding-right: 10px; border-right: 1px solid var(--border-subtle); }
.railHighlight { position: absolute; top: 0; left: 0; border-radius: 12px; background: color-mix(in oklab, var(--foreground) 6.5%, transparent); pointer-events: none; }
.preset {
position: relative;
display: flex;
height: 32px;
flex: none;
align-items: center;
padding: 0 12px;
border: 0;
border-radius: 12px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font: inherit;
text-align: left;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);
}
.preset[aria-pressed="true"] { color: var(--foreground); font-weight: 500; }
.calendars { position: relative; padding: 0 2px; }
.navButton {
position: absolute;
z-index: 2;
top: 0;
display: grid;
width: 32px;
height: 32px;
place-items: center;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);
}
.navButton[data-side="prev"] { left: 2px; }
.navButton[data-side="next"] { right: 2px; }
.navButton:active:not(:disabled) { transform: scale(.92); }
.navButton:disabled { opacity: .35; cursor: default; }
/* Months slide horizontally inside a clipped window that keeps a little room for the blur. */
.viewport { position: relative; overflow: clip; overflow-clip-margin: 2px; }
.months { display: flex; gap: 24px; }
.month { width: calc(var(--cell) * 7); }
.monthTitle { height: 32px; margin: 0; color: var(--foreground); font-weight: 500; line-height: 32px; text-align: center; white-space: nowrap; }
.grid { display: grid; }
.weekdays { display: grid; height: 28px; align-items: center; color: var(--text-muted); font-size: var(--text-xs); grid-template-columns: repeat(7, var(--cell)); text-align: center; }
.weeks { position: relative; }
.week { display: grid; height: var(--cell); grid-template-columns: repeat(7, var(--cell)); }
.cell, .blank { position: relative; display: block; }
/* The highlight lives under the days: one bar per week plus a solid end at each side of the range. */
.layer { position: absolute; inset: 0; pointer-events: none; }
.bar { position: absolute; height: calc(var(--cell) - 6px); border-radius: var(--radius-pill); background: var(--accent-subtle); }
.thumb { position: absolute; top: 0; left: 0; width: var(--cell); height: var(--cell); }
.thumb::before { content: ""; position: absolute; inset: 3px; border-radius: 50%; background: var(--accent); }
.day {
position: relative;
z-index: 1;
display: grid;
width: 100%;
height: 100%;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--foreground);
cursor: pointer;
font: inherit;
font-variant-numeric: tabular-nums;
-webkit-tap-highlight-color: transparent;
transition: color var(--duration-fast) var(--ease-standard);
}
.day > span { position: relative; display: grid; width: calc(100% - 6px); height: calc(100% - 6px); place-items: center; border-radius: 50%; transition: background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.day:active:not(:disabled) > span { transform: scale(.9); }
.day[data-edge] { color: var(--accent-foreground); font-weight: 500; transition-delay: 40ms; }
.day[data-today]::after { content: ""; position: absolute; bottom: 5px; left: 50%; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: var(--accent); }
.day[data-today][data-edge]::after { background: var(--accent-foreground); }
.day:disabled { color: var(--text-muted); cursor: default; opacity: .5; }
.day:focus-visible:not([data-edge]) > span { background: color-mix(in oklab, var(--foreground) 7%, transparent); }
/* Footer: the formatted range and the two decisions. */
.footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; padding: 10px 2px 0 12px; border-top: 1px solid var(--border-subtle); }
.summary { display: grid; min-width: 0; gap: 1px; }
.summaryValue { display: inline-flex; width: max-content; font-weight: 500; font-variant-numeric: tabular-nums; }
.summaryCount { display: inline-flex; min-height: 1.4em; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.actions { display: flex; flex: none; align-items: center; gap: 6px; }
.ghost, .primary {
display: inline-flex;
height: var(--control-height-sm);
align-items: center;
justify-content: center;
padding: 0 14px;
border: 0;
border-radius: var(--radius-control);
cursor: pointer;
font: inherit;
font-weight: 500;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard);
}
.ghost { background: transparent; color: var(--text-secondary); }
.primary { min-width: 4.75rem; background: var(--accent); color: var(--accent-foreground); }
.ghost:active, .primary:active:not(:disabled) { transform: scale(.97); }
.primary:disabled { opacity: .45; cursor: default; }
.ghost:focus-visible { background: var(--surface-muted); color: var(--foreground); }
.preset:focus-visible, .navButton:focus-visible { background: color-mix(in oklab, var(--foreground) 5%, transparent); color: var(--foreground); }
.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
/* Compact: one month under a scrolling row of presets. */
.panel[data-compact] { padding: 8px; }
.panel[data-compact] .body { flex-direction: column; gap: 8px; }
.panel[data-compact] .rail { width: auto; flex-direction: row; gap: 2px; margin: 0 -8px; padding: 0 8px 8px; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--border-subtle); overscroll-behavior-x: contain; scrollbar-width: none; }
.panel[data-compact] .rail::-webkit-scrollbar { display: none; }
.panel[data-compact] .preset { padding: 0 11px; }
.panel[data-compact] .calendars { align-self: center; }
.panel[data-compact] .footer { flex-wrap: wrap; margin-top: 6px; padding-left: 11px; }
.panel[data-compact] .actions { margin-left: auto; }
.panel[data-compact] .summary { flex: 1 1 8rem; }
@media (hover: hover) and (pointer: fine) {
.root:not([data-open]):has(> .trigger:hover:not(:disabled)) .surface { background: color-mix(in oklab, var(--foreground) 3%, var(--surface-raised)); }
.root:not([data-open]):has(> .trigger:hover:not(:disabled)) .surface::after { border-color: var(--border-strong); }
.preset:hover { color: var(--foreground); }
.preset:not([aria-pressed="true"]):hover { background: color-mix(in oklab, var(--foreground) 4%, transparent); }
.navButton:hover:not(:disabled) { background: color-mix(in oklab, var(--foreground) 6.5%, transparent); color: var(--foreground); }
.day:hover:not(:disabled):not([data-edge]) > span { background: color-mix(in oklab, var(--foreground) 7%, transparent); }
.ghost:hover { background: var(--surface-muted); color: var(--foreground); }
.primary:hover:not(:disabled) { background: var(--accent-strong); }
}
@media (prefers-reduced-motion: reduce) {
.surface, .preset, .navButton, .day, .day > span, .ghost, .primary { transition-duration: 0ms; }
}
@media (prefers-contrast: more) {
.surface::after { border-color: var(--border-strong); }
}
date-range-picker.tsxdate-range-picker.tsxdate-range-picker.tsxtsx
"use client";
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, CSSProperties, FocusEvent as ReactFocusEvent, ReactNode } from "react";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useTransform } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { Transition, Variants } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import { useToday } from "../lib/use-today";
import styles from "./date-range-picker.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
/** An inclusive range of whole days. */
export interface DateRange {
start: Date;
end: Date;
}
/** A shortcut in the preset rail. `range` receives the viewer's local today. */
export interface DateRangePreset {
label: string;
range: (today: Date) => DateRange;
}
/**
* A range picker whose trigger grows into the panel it opens. The panel shows two months beside a preset rail, or one
* month with a scrolling preset row when space is tight. The range highlight stretches across each week as you hover
* or move with the keyboard, the ends glide between days, months slide in the direction you travel, and on Apply the
* formatted label flies back into the trigger as the surface shrinks around it.
* Use it for reports, filters, and bookings where a start and end date are chosen together.
*/
export interface DateRangePickerProps {
/** Controlled value. Pass `null` for no selection. */
value?: DateRange | null;
/** Uncontrolled starting value. */
defaultValue?: DateRange | null;
/** Called with the applied range. */
onChange?: (range: DateRange) => void;
/** Accessible name of the trigger and the dialog. 기본값은 "기간"이에요. */
label?: string;
placeholder?: string;
presets?: DateRangePreset[];
minDate?: Date;
maxDate?: Date;
/** 0 is Sunday, 1 is Monday. Defaults to 0. */
weekStartsOn?: 0 | 1;
locale?: string;
/** Force one or two months. `auto` picks two when the boundary is wide enough. */
months?: "auto" | 1 | 2;
/** The element the panel should stay inside. Defaults to the viewport. */
boundary?: () => HTMLElement | null;
className?: string;
}
type Size = { w: number; h: number };
type Bezier = [number, number, number, number];
const DAY = 864e5;
const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
const monthStart = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1);
const monthEnd = (date: Date) => new Date(date.getFullYear(), date.getMonth() + 1, 0);
const addDays = (date: Date, amount: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);
const addMonths = (date: Date, amount: number) => new Date(date.getFullYear(), date.getMonth() + amount, 1);
const shiftMonths = (date: Date, amount: number) => new Date(date.getFullYear(), date.getMonth() + amount, Math.min(date.getDate(), new Date(date.getFullYear(), date.getMonth() + amount + 1, 0).getDate()));
const dayDiff = (a: Date, b: Date) => Math.round((startOfDay(a).getTime() - startOfDay(b).getTime()) / DAY);
const monthDiff = (a: Date, b: Date) => (a.getFullYear() - b.getFullYear()) * 12 + a.getMonth() - b.getMonth();
const sameDay = (a?: Date | null, b?: Date | null) => Boolean(a && b && dayDiff(a, b) === 0);
const keyOf = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const fromKey = (key: string) => { const [year, month, day] = key.split("-").map(Number); return new Date(year!, month! - 1, day!); };
const ordered = (a: Date, b: Date): DateRange => dayDiff(a, b) <= 0 ? { start: a, end: b } : { start: b, end: a };
const sameRange = (a?: DateRange | null, b?: DateRange | null) => Boolean(a && b && sameDay(a.start, b.start) && sameDay(a.end, b.end));
const clampDate = (date: Date, min?: Date, max?: Date) => min && dayDiff(date, min) < 0 ? startOfDay(min) : max && dayDiff(date, max) > 0 ? startOfDay(max) : date;
export const defaultDateRangePresets: DateRangePreset[] = [
{ label: "오늘", range: today => ({ start: today, end: today }) },
{ label: "어제", range: today => ({ start: addDays(today, -1), end: addDays(today, -1) }) },
{ label: "최근 7일", range: today => ({ start: addDays(today, -6), end: today }) },
{ label: "최근 30일", range: today => ({ start: addDays(today, -29), end: today }) },
{ label: "이번 달", range: today => ({ start: monthStart(today), end: today }) },
{ label: "지난달", range: today => ({ start: addMonths(today, -1), end: monthEnd(addMonths(today, -1)) }) },
{ label: "이번 분기", range: today => ({ start: new Date(today.getFullYear(), Math.floor(today.getMonth() / 3) * 3, 1), end: today }) },
{ label: "올해 지금까지", range: today => ({ start: new Date(today.getFullYear(), 0, 1), end: today }) },
];
/** The viewer's local date (lib/use-today.ts, shared with calendar). Re-exported so existing imports from date-range-picker keep working. */
export { useToday } from "../lib/use-today";
const noop = () => () => {};
function useReducedFlag() {
const hydrated = useSyncExternalStore(noop, () => true, () => false);
return !!useReducedMotion() && hydrated;
}
const { blur } = motionTokens;
const enterEase = [...motionTokens.ease.enter] as Bezier;
const standardEase = [...motionTokens.ease.standard] as Bezier;
/** Duration springs restated as stiffness and damping, so every retarget keeps the velocity already in flight. */
const physical = (visualDuration: number, bounce: number): Transition => {
const root = 2 * Math.PI / (visualDuration * 1.2);
return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };
};
const GROW = physical(.48, .12), SHRINK = physical(.38, 0), STRETCH = physical(.3, .04), GLIDE = physical(.3, .1), SLIDE = physical(.4, .06);
const TRIGGER_RADIUS = 18, PANEL_RADIUS = 26, WIDE_CELL = 36, WIDE_MIN = 712, EDGE = 8;
const roll: Variants = {
enter: (direction: number) => ({ opacity: 0, y: `${direction * .45}em`, filter: `blur(${blur.subtle}px)` }),
center: { opacity: 1, y: "0em", filter: "blur(0px)", transition: { duration: motionTokens.duration.moderate, ease: enterEase } },
exit: (direction: number) => ({ opacity: 0, y: `${direction * -.45}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standardEase } }),
};
const fade: Variants = { enter: { opacity: 0 }, center: { opacity: 1, y: "0em", filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.micro } } };
/** Panel content arrives just behind the growing surface and leaves ahead of it. */
const faceIn: Variants = {
hidden: { opacity: 0, y: -4, filter: `blur(${blur.soft}px)` },
shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.moderate, ease: enterEase, delay: motionTokens.delay.short } },
gone: { opacity: 0, y: -2, filter: `blur(${blur.soft}px)`, transition: { duration: motionTokens.duration.instant, ease: standardEase } },
};
const faceFade: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.micro } } };
const slide: Variants = {
enter: (direction: number) => ({ opacity: 0, x: direction * 48, filter: `blur(${blur.soft}px)` }),
center: { opacity: 1, x: 0, filter: "blur(0px)", transition: { x: SLIDE, opacity: { duration: motionTokens.duration.standard, ease: enterEase }, filter: { duration: motionTokens.duration.standard, ease: enterEase } } },
exit: (direction: number) => ({ opacity: 0, x: direction * -36, filter: `blur(${blur.soft}px)`, transition: { x: SLIDE, opacity: { duration: motionTokens.duration.instant, ease: standardEase }, filter: { duration: motionTokens.duration.instant, ease: standardEase } } }),
};
/** Words roll one by one: a later date rises from below, an earlier one drops from above; words that stay the same hold still. */
function Rolling({ text, direction, reduced }: { text: string; direction: number; reduced: boolean }) {
const words = text.split(/\s+/).filter(Boolean);
return <span className={styles.rolling} aria-hidden="true">
{words.map((word, index) => <span key={index} className={styles.word}>
<AnimatePresence initial={false} mode="popLayout" custom={direction}>
<motion.span key={word} className={styles.wordInner} custom={direction} variants={reduced ? fade : roll} initial="enter" animate="center" exit="exit">{word}</motion.span>
</AnimatePresence>
</span>)}
</span>;
}
/** Remembers which way a range moved, so labels roll in time's direction. */
function useDirection(range: DateRange | null) {
const time = range ? range.start.getTime() * 2 + range.end.getTime() : null;
const [previous, setPrevious] = useState(time);
const [direction, setDirection] = useState(1);
if (time !== previous) { setPrevious(time); setDirection(time === null || previous === null || time >= previous ? 1 : -1); }
return direction;
}
type Segment = { left: number; width: number } | null;
const cellIn = (date: Date, first: Date) => { const index = dayDiff(date, first); return { row: Math.floor(index / 7), col: index % 7 }; };
/** One week's slice of the highlight. It grows out of the edge the range arrives from, so hovering reads as stretching. */
function Bar({ row, segment, reduced }: { row: number; segment: Segment; reduced: boolean }) {
const left = useMotionValue(segment?.left ?? 0), width = useMotionValue(segment?.width ?? 0), opacity = useMotionValue(segment ? 1 : 0);
const leftPct = useTransform(left, value => `${value}%`), widthPct = useTransform(width, value => `${value}%`);
const wasEmpty = useRef(!segment);
useLayoutEffect(() => {
if (!segment) { wasEmpty.current = true; animate(opacity, 0, { duration: reduced ? 0 : motionTokens.duration.instant }); return; }
if (wasEmpty.current || reduced) { left.jump(segment.left); width.jump(segment.width); }
else { animate(left, segment.left, STRETCH); animate(width, segment.width, STRETCH); }
wasEmpty.current = false;
animate(opacity, 1, { duration: reduced ? 0 : motionTokens.duration.fast });
}, [left, opacity, reduced, segment, width]);
return <motion.span className={styles.bar} style={{ left: leftPct, width: widthPct, opacity, top: `calc(${row} * var(--cell) + 3px)` }} />;
}
/** A solid end of the range. It glides from day to day and only fades when the end leaves this month. */
function Thumb({ cell, reduced }: { cell: { row: number; col: number } | null; reduced: boolean }) {
const x = useMotionValue(cell?.col ?? 0), y = useMotionValue(cell?.row ?? 0), opacity = useMotionValue(cell ? 1 : 0), scale = useMotionValue(cell ? 1 : .8);
const transform = useTransform(() => `translate(${x.get() * 100}%, ${y.get() * 100}%) scale(${scale.get()})`);
const hidden = useRef(!cell);
useLayoutEffect(() => {
if (!cell) { hidden.current = true; animate(opacity, 0, { duration: reduced ? 0 : motionTokens.duration.instant }); animate(scale, .8, { duration: reduced ? 0 : motionTokens.duration.instant }); return; }
if (hidden.current || reduced) { x.jump(cell.col); y.jump(cell.row); }
else { animate(x, cell.col, GLIDE); animate(y, cell.row, GLIDE); }
hidden.current = false;
animate(opacity, 1, { duration: reduced ? 0 : motionTokens.duration.fast });
animate(scale, 1, reduced ? { duration: 0 } : GLIDE);
}, [cell, opacity, reduced, scale, x, y]);
return <motion.span className={styles.thumb} style={{ transform, opacity }} />;
}
interface MonthProps {
month: Date;
range: DateRange | null;
tabbable: string;
today?: Date;
minDate?: Date;
maxDate?: Date;
weekStartsOn: 0 | 1;
reduced: boolean;
formatters: { title: Intl.DateTimeFormat; day: Intl.DateTimeFormat; weekday: Intl.DateTimeFormat; weekdayLong: Intl.DateTimeFormat };
onPick: (date: Date) => void;
onHover: (date: Date) => void;
onKey: (event: ReactKeyboardEvent<HTMLButtonElement>, date: Date) => void;
onFocusDay: (date: Date) => void;
idBase: string;
}
function Month({ month, range, tabbable, today, minDate, maxDate, weekStartsOn, reduced, formatters, onPick, onHover, onKey, onFocusDay, idBase }: MonthProps) {
const first = useMemo(() => addDays(month, -((month.getDay() - weekStartsOn + 7) % 7)), [month, weekStartsOn]);
const rows = useMemo(() => Array.from({ length: 6 }, (_, row) => Array.from({ length: 7 }, (_, col) => addDays(first, row * 7 + col))), [first]);
const titleId = `${idBase}-${keyOf(month)}`;
const inMonth = (date: Date) => monthDiff(date, month) === 0;
const lo = range?.start, hi = range?.end;
const loKey = lo ? keyOf(lo) : "", hiKey = hi ? keyOf(hi) : "";
/** Each week's slice of the range, in percent of the row. Keyed by the range's days so bars only move when it does. */
const segments = useMemo<Segment[]>(() => {
if (!loKey || !hiKey) return rows.map(() => null);
const start = fromKey(loKey), end = fromKey(hiKey);
const unit = 100 / 7;
const colOf = (date: Date) => dayDiff(date, first) % 7;
return rows.map(row => {
const days = row.filter(date => monthDiff(date, month) === 0);
if (!days.length) return null;
const rowFirst = days[0]!, rowLast = days[days.length - 1]!;
const from = dayDiff(start, rowFirst) > 0 ? start : rowFirst, to = dayDiff(end, rowLast) < 0 ? end : rowLast;
if (dayDiff(from, to) <= 0) { const a = colOf(from), b = colOf(to); return { left: a * unit, width: (b - a + 1) * unit }; }
// The week sits wholly before or after the range: collapse at the edge the range would grow in from.
if (dayDiff(rowLast, start) < 0) return { left: (colOf(rowLast) + 1) * unit, width: 0 };
return { left: colOf(rowFirst) * unit, width: 0 };
});
}, [first, hiKey, loKey, month, rows]);
const startCell = useMemo(() => loKey && monthDiff(fromKey(loKey), month) === 0 ? cellIn(fromKey(loKey), first) : null, [loKey, month, first]);
const endCell = useMemo(() => hiKey && hiKey !== loKey && monthDiff(fromKey(hiKey), month) === 0 ? cellIn(fromKey(hiKey), first) : null, [hiKey, loKey, month, first]);
return <div className={styles.month}>
<p id={titleId} className={styles.monthTitle}>{formatters.title.format(month)}</p>
<div role="grid" aria-labelledby={titleId} className={styles.grid}>
<div role="row" className={styles.weekdays}>
{rows[0]!.map(date => <span key={date.getDay()} role="columnheader" aria-label={formatters.weekdayLong.format(date)}>{formatters.weekday.format(date).slice(0, 2)}</span>)}
</div>
<div className={styles.weeks}>
<span className={styles.layer} aria-hidden="true">
{segments.map((segment, row) => <Bar key={row} row={row} segment={segment} reduced={reduced} />)}
<Thumb cell={startCell} reduced={reduced} />
<Thumb cell={endCell} reduced={reduced} />
</span>
{rows.map((row, index) => <div key={index} role="row" className={styles.week}>
{row.map(date => {
if (!inMonth(date)) return <span key={keyOf(date)} role="gridcell" className={styles.blank} />;
const key = keyOf(date);
const disabled = Boolean((minDate && dayDiff(date, minDate) < 0) || (maxDate && dayDiff(date, maxDate) > 0));
const inRange = Boolean(lo && hi && dayDiff(date, lo) >= 0 && dayDiff(date, hi) <= 0);
const edge = sameDay(date, lo) || sameDay(date, hi);
const isToday = sameDay(date, today);
return <span key={key} role="gridcell" aria-selected={inRange} className={styles.cell}>
<button type="button" className={styles.day} data-date={key} data-edge={edge || undefined} data-range={inRange || undefined} data-today={isToday || undefined}
tabIndex={key === tabbable ? 0 : -1} disabled={disabled} aria-current={isToday ? "date" : undefined}
aria-label={formatters.day.format(date)}
onClick={() => onPick(date)} onPointerEnter={() => { if (!disabled) onHover(date); }}
onFocus={() => onFocusDay(date)} onKeyDown={event => onKey(event, date)}>
<span>{date.getDate()}</span>
</button>
</span>;
})}
</div>)}
</div>
</div>
</div>;
}
/** The visible months travel together; a leaving set turns inert so focus and queries only find the current one. */
function Months({ children, direction, reduced }: { children: ReactNode; direction: number; reduced: boolean }) {
const present = useIsPresent();
return <motion.div className={styles.months} data-current={present || undefined} inert={!present || undefined} custom={direction}
variants={reduced ? fade : slide} initial="enter" animate="center" exit="exit">{children}</motion.div>;
}
export function DateRangePicker({ value, defaultValue = null, onChange, label = "기간", placeholder = "기간을 선택해 주세요", presets = defaultDateRangePresets, minDate, maxDate, weekStartsOn = 0, locale = "ko-KR", months = "auto", boundary, className }: DateRangePickerProps) {
const reduced = useReducedFlag();
const today = useToday();
const uid = useId();
const [inner, setInner] = useState<DateRange | null>(defaultValue);
const committed = value !== undefined ? value : inner;
const [open, setOpen] = useState(false);
const [compact, setCompact] = useState(false);
const [panelWidth, setPanelWidth] = useState(0);
const [view, setView] = useState<Date>(() => monthStart(committed?.end ?? new Date(2000, 0, 1)));
const [direction, setDirection] = useState(1);
const [draft, setDraft] = useState<DateRange | null>(committed);
const [anchor, setAnchor] = useState<Date | null>(null);
const [hover, setHover] = useState<Date | null>(null);
const [focusKey, setFocusKey] = useState("");
const formatters = useMemo(() => ({
label: new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric" }),
title: new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
day: new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric" }),
weekday: new Intl.DateTimeFormat(locale, { weekday: "short" }),
weekdayLong: new Intl.DateTimeFormat(locale, { weekday: "long" }),
}), [locale]);
const format = useCallback((range: DateRange | null) => !range ? placeholder : sameDay(range.start, range.end) ? formatters.label.format(range.start) : formatters.label.formatRange(range.start, range.end), [formatters, placeholder]);
const count = compact ? 1 : 2;
const visible = useMemo(() => Array.from({ length: count }, (_, index) => addMonths(view, index)), [count, view]);
const shown = anchor ? ordered(anchor, hover ?? anchor) : draft;
const activePreset = today && !anchor ? presets.findIndex(preset => sameRange(preset.range(today), draft)) : -1;
const days = shown ? dayDiff(shown.end, shown.start) + 1 : 0;
const committedDirection = useDirection(committed);
const shownDirection = useDirection(shown);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const railRef = useRef<HTMLDivElement>(null);
const pendingFocus = useRef<"trigger" | "day" | null>(null);
/* ---------- One surface: the trigger's box springs to the panel's box and back ---------- */
// Before the first measurement the surface simply fills the root and the root wraps the trigger, so server markup already looks right.
const width = useMotionValue<number | string>("100%"), height = useMotionValue<number | string>("100%"), radius = useMotionValue(TRIGGER_RADIUS), x = useMotionValue(0), rootWidth = useMotionValue<number | string>("auto");
const sizes = useRef<{ trigger: Size; panel: Size | null }>({ trigger: { w: 0, h: 0 }, panel: null });
const live = useRef({ open: false, reduced: false, ready: false });
const queued = useRef(false);
const boundaryRef = useRef(boundary);
useLayoutEffect(() => { boundaryRef.current = boundary; }, [boundary]);
const bounds = useCallback(() => {
const element = boundaryRef.current?.();
const viewport = { left: 0, right: document.documentElement.clientWidth };
if (!element) return viewport;
const rect = element.getBoundingClientRect();
return { left: Math.max(viewport.left, rect.left), right: Math.min(viewport.right, rect.right) };
}, []);
const update = useCallback(() => {
queued.current = false;
const { trigger, panel } = sizes.current, state = live.current;
if (!trigger.w) return;
const openPanel = state.open && panel ? panel : null;
const target = openPanel ?? trigger;
let offset = 0;
if (openPanel && rootRef.current) {
const rect = rootRef.current.getBoundingClientRect(), box = bounds();
offset = Math.min(0, box.right - EDGE - (rect.left + openPanel.w));
offset = Math.max(offset, box.left + EDGE - rect.left);
}
const r = openPanel ? PANEL_RADIUS : TRIGGER_RADIUS;
if (!state.ready || state.reduced) {
width.jump(target.w); height.jump(target.h); radius.jump(r); x.jump(offset); rootWidth.jump(trigger.w);
state.ready = true;
return;
}
const spring = openPanel ? GROW : state.open ? GROW : SHRINK;
animate(width, target.w, spring); animate(height, target.h, spring); animate(radius, r, spring); animate(x, offset, spring);
animate(rootWidth, trigger.w, motionTokens.spring.morph);
}, [bounds, height, radius, rootWidth, width, x]);
const schedule = useCallback(() => {
if (queued.current) return;
queued.current = true;
queueMicrotask(update);
}, [update]);
useLayoutEffect(() => { live.current.open = open; live.current.reduced = reduced; if (!open) sizes.current.panel = null; schedule(); }, [open, reduced, schedule]);
useLayoutEffect(() => {
const node = triggerRef.current;
if (!node) return;
const read = () => { sizes.current.trigger = { w: node.offsetWidth, h: node.offsetHeight }; schedule(); };
read();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(read);
observer.observe(node);
return () => observer.disconnect();
}, [schedule]);
useLayoutEffect(() => {
const node = panelRef.current;
if (!open || !node) return;
const read = () => { sizes.current.panel = { w: node.offsetWidth, h: node.offsetHeight }; schedule(); };
read();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(read);
observer.observe(node);
return () => observer.disconnect();
}, [open, schedule]);
// On a short screen (a phone, a trigger low on the page) the panel can open below the fold, with Apply out of reach.
// Once it has its size, scroll just enough to bring its bottom into view, never past its top. After closing, the
// trigger comes back into view if that scroll left it off screen.
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
const node = panelRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
const overflow = rect.bottom - (window.innerHeight - EDGE);
if (overflow <= 0) return;
window.scrollBy({ top: Math.min(overflow, Math.max(0, rect.top - EDGE)), behavior: reduced ? "instant" : "smooth" });
});
return () => cancelAnimationFrame(frame);
}, [open, reduced]);
useEffect(() => {
if (open) return;
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) trigger.scrollIntoView({ block: "center", behavior: reduced ? "instant" : "smooth" });
}, [open, reduced]);
/* ---------- Layout: two months when there is room, one compact month otherwise ---------- */
const measureLayout = useCallback(() => {
const box = bounds();
const available = Math.max(0, box.right - box.left - EDGE * 2);
const single = months === 1 || (months === "auto" && available < WIDE_MIN);
return { single, width: Math.min(352, available) };
}, [bounds, months]);
useEffect(() => {
if (!open) return;
const onResize = () => { const next = measureLayout(); setCompact(next.single); setPanelWidth(next.width); schedule(); };
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [measureLayout, open, schedule]);
const viewFor = (range: DateRange | null, single: boolean, fallback: Date) => {
const end = monthStart(range?.end ?? fallback);
return single ? end : addMonths(end, -1);
};
const openPanel = () => {
if (!today) return;
const layout = measureLayout();
setCompact(layout.single);
setPanelWidth(layout.width);
setDraft(committed);
setAnchor(null);
setHover(null);
setView(viewFor(committed, layout.single, today));
setDirection(0);
setFocusKey(keyOf(committed?.start ?? today));
pendingFocus.current = "day";
playUiSound("open");
setOpen(true);
};
// Applying plays its own sound, so it closes quietly.
const close = useCallback((focus: boolean, quiet = false) => {
if (!quiet) playUiSound("close");
if (focus) pendingFocus.current = "trigger";
setDirection(0);
setOpen(false);
setAnchor(null);
setHover(null);
}, []);
const apply = () => {
const next = anchor ? { start: anchor, end: anchor } : draft;
if (!next) return;
if (value === undefined) setInner(next);
playUiSound("select");
onChange?.(next);
close(true, true);
};
/* ---------- Focus management ---------- */
useLayoutEffect(() => {
const target = pendingFocus.current;
if (!target) return;
if (target === "trigger" && !open) { pendingFocus.current = null; triggerRef.current?.focus({ preventScroll: true }); return; }
if (target === "day" && open) {
const node = panelRef.current?.querySelector<HTMLButtonElement>(`[data-current] [data-date="${focusKey}"]`);
if (node) { pendingFocus.current = null; node.focus({ preventScroll: true }); }
}
}, [focusKey, open, view, compact]);
useEffect(() => {
if (!open) return;
const down = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) close(false); };
document.addEventListener("pointerdown", down);
return () => document.removeEventListener("pointerdown", down);
}, [close, open]);
const onRootKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape" && open) { event.preventDefault(); event.stopPropagation(); close(true); }
};
const onRootBlur = (event: ReactFocusEvent<HTMLDivElement>) => {
const next = event.relatedTarget as Node | null;
if (open && next && !event.currentTarget.contains(next)) close(false);
};
/* ---------- Month navigation and picking ---------- */
const goTo = (next: Date) => {
const delta = monthDiff(next, view);
if (!delta) return;
setDirection(Math.sign(delta));
setView(next);
};
/** Keeps a range on screen: nothing moves when it already fits, otherwise its last month lands on the right. */
const reveal = (range: DateRange) => {
const first = view, last = addMonths(view, count - 1);
if (monthDiff(range.start, first) >= 0 && monthDiff(range.end, last) <= 0) return;
goTo(viewFor(range, compact, range.end));
};
const pick = (date: Date) => {
playUiSound("select");
setFocusKey(keyOf(date));
if (!anchor) { setAnchor(date); setHover(date); return; }
setDraft(ordered(anchor, date));
setAnchor(null);
setHover(null);
};
const choosePreset = (preset: DateRangePreset) => {
if (!today) return;
const range = preset.range(today);
playUiSound("select");
setDraft(range);
setAnchor(null);
setHover(null);
setFocusKey(keyOf(range.start));
reveal(range);
};
const onDayKey = (event: ReactKeyboardEvent<HTMLButtonElement>, date: Date) => {
const weekday = (date.getDay() - weekStartsOn + 7) % 7;
const moves: Record<string, () => Date> = {
ArrowLeft: () => addDays(date, -1), ArrowRight: () => addDays(date, 1), ArrowUp: () => addDays(date, -7), ArrowDown: () => addDays(date, 7),
Home: () => addDays(date, -weekday), End: () => addDays(date, 6 - weekday),
PageUp: () => shiftMonths(date, event.shiftKey ? -12 : -1), PageDown: () => shiftMonths(date, event.shiftKey ? 12 : 1),
};
const move = moves[event.key];
if (!move) return;
event.preventDefault();
const next = clampDate(move(), minDate, maxDate);
setFocusKey(keyOf(next));
if (anchor) setHover(next);
pendingFocus.current = "day";
const first = view, last = addMonths(view, count - 1);
if (monthDiff(next, first) < 0) goTo(monthStart(next));
else if (monthDiff(next, last) > 0) goTo(addMonths(monthStart(next), -(count - 1)));
};
const onRailKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const keys = compact ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
if (!keys.includes(event.key)) return;
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("[data-preset]"));
const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (at < 0) return;
event.preventDefault();
buttons[(at + (event.key === keys[1] ? 1 : -1) + buttons.length) % buttons.length]?.focus();
};
/** The day that owns the tab stop: the focused day when it is on screen, else the first visible day of the range, else the 1st. */
const tabbable = useMemo(() => {
const onScreen = (date: Date) => visible.some(month => monthDiff(date, month) === 0);
if (focusKey && onScreen(fromKey(focusKey))) return focusKey;
if (shown && onScreen(shown.start)) return keyOf(shown.start);
return keyOf(visible[0]!);
}, [focusKey, shown, visible]);
/* ---------- Preset highlight glides between presets ---------- */
const gx = useMotionValue(0), gy = useMotionValue(0), gw = useMotionValue(0), gh = useMotionValue(0), go = useMotionValue(0);
useLayoutEffect(() => {
const rail = railRef.current;
const node = activePreset < 0 ? null : rail?.querySelector<HTMLElement>(`[data-preset="${activePreset}"]`);
if (!rail || !node) { animate(go, 0, { duration: reduced ? 0 : motionTokens.duration.instant }); return; }
const place = [node.offsetLeft, node.offsetTop, node.offsetWidth, node.offsetHeight] as const;
if (go.get() < .05 || reduced) { gx.jump(place[0]); gy.jump(place[1]); gw.jump(place[2]); gh.jump(place[3]); }
else { animate(gx, place[0], GLIDE); animate(gy, place[1], GLIDE); animate(gw, place[2], GLIDE); animate(gh, place[3], GLIDE); }
animate(go, 1, { duration: reduced ? 0 : motionTokens.duration.fast });
if (compact && (node.offsetLeft < rail.scrollLeft || node.offsetLeft + node.offsetWidth > rail.scrollLeft + rail.clientWidth)) {
rail.scrollTo({ left: node.offsetLeft - 12, behavior: reduced ? "auto" : "smooth" });
}
}, [activePreset, compact, go, gh, gw, gx, gy, open, reduced]);
/* ---------- Copy ---------- */
const committedText = format(committed);
const shownText = shown ? format(shown) : "날짜 없음";
const countText = shown ? `${days}일` : "";
const status = !open ? "" : anchor ? `시작일 ${formatters.label.format(anchor)}. 끝나는 날을 골라 주세요.` : shown ? `${shownText}, ${countText}` : "";
const layoutId = reduced ? undefined : `${uid}-value`;
const cellSize = compact && panelWidth ? Math.max(32, Math.min(42, Math.floor((panelWidth - 24) / 7))) : WIDE_CELL;
const quiet = open ? (reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${blur.subtle}px)` }) : { opacity: 1, filter: "blur(0px)" };
const quietTransition = open ? { duration: motionTokens.duration.instant, ease: standardEase } : { duration: motionTokens.duration.standard, ease: enterEase, delay: reduced ? 0 : motionTokens.delay.short };
return <motion.div ref={rootRef} className={[styles.root, className].filter(Boolean).join(" ")} style={{ width: rootWidth }} data-open={open || undefined} onKeyDown={onRootKey} onBlur={onRootBlur}>
{/* The trigger stays in place under the panel. Its icons fade, while the value itself flies to the panel footer and back. */}
<button ref={triggerRef} type="button" className={styles.trigger} aria-haspopup="dialog" aria-expanded={open} aria-controls={open ? `${uid}-panel` : undefined}
aria-label={`${label}: ${committedText}`} disabled={!today} inert={open || undefined} onClick={openPanel}>
<motion.span className={styles.triggerIcon} initial={false} animate={quiet} transition={quietTransition}><UiIcon iconRole="calendar" size={16} strokeWidth={1.75} aria-hidden="true" /></motion.span>
{open || !layoutId
? <motion.span key="resting" className={styles.value} data-empty={!committed || undefined} initial={false} animate={open ? { opacity: 0 } : { opacity: 1 }} transition={quietTransition}>
<Rolling text={committedText} direction={committedDirection} reduced={reduced} />
</motion.span>
: <motion.span key="shared" layoutId={layoutId} layout="position" className={styles.value} data-empty={!committed || undefined} transition={SHRINK}>
<Rolling text={committedText} direction={committedDirection} reduced={reduced} />
</motion.span>}
<motion.span className={styles.chevron} initial={false} animate={quiet} transition={quietTransition}><UiIcon iconRole="chevron-down" size={16} strokeWidth={1.75} aria-hidden="true" /></motion.span>
</button>
<motion.div className={styles.surface} style={{ width, height, borderRadius: radius, x }}>
<AnimatePresence>
{open && today && <motion.div key="panel" ref={panelRef} id={`${uid}-panel`} className={styles.panel} role="dialog" aria-label={label} data-compact={compact || undefined}
style={{ "--cell": `${cellSize}px`, width: compact && panelWidth ? panelWidth : undefined } as CSSProperties} exit={{ opacity: 1, transition: { duration: motionTokens.duration.instant } }}>
<motion.div className={styles.body} variants={reduced ? faceFade : faceIn} initial="hidden" animate="shown" exit="gone">
<div ref={railRef} className={styles.rail} role="group" aria-label="빠른 선택" onKeyDown={onRailKey}>
<motion.span className={styles.railHighlight} style={{ x: gx, y: gy, width: gw, height: gh, opacity: go }} aria-hidden="true" />
{presets.map((preset, index) => <button key={preset.label} type="button" className={styles.preset} data-preset={index} aria-pressed={index === activePreset}
onClick={() => choosePreset(preset)}>{preset.label}</button>)}
</div>
<div className={styles.calendars} onPointerLeave={() => { if (anchor) setHover(null); }}>
<button type="button" className={styles.navButton} data-side="prev" aria-label="이전 달" onClick={() => goTo(addMonths(view, -1))}
disabled={Boolean(minDate && monthDiff(view, minDate) <= 0)}><UiIcon iconRole="chevron-left" size={16} strokeWidth={1.75} aria-hidden="true" /></button>
<button type="button" className={styles.navButton} data-side="next" aria-label="다음 달" onClick={() => goTo(addMonths(view, 1))}
disabled={Boolean(maxDate && monthDiff(addMonths(view, count - 1), maxDate) >= 0)}><UiIcon iconRole="chevron-right" size={16} strokeWidth={1.75} aria-hidden="true" /></button>
<div className={styles.viewport}>
<AnimatePresence initial={false} mode="popLayout" custom={direction}>
<Months key={`${keyOf(view)}-${count}`} direction={direction} reduced={reduced}>
{visible.map(month => <Month key={keyOf(month)} month={month} range={shown} tabbable={tabbable} today={today} minDate={minDate} maxDate={maxDate}
weekStartsOn={weekStartsOn} reduced={reduced} formatters={formatters} idBase={uid}
onPick={pick} onHover={date => { if (anchor) setHover(date); }} onKey={onDayKey} onFocusDay={date => setFocusKey(keyOf(date))} />)}
</Months>
</AnimatePresence>
</div>
</div>
</motion.div>
<div className={styles.footer}>
<div className={styles.summary}>
<motion.span layoutId={layoutId} layout={layoutId ? "position" : undefined} className={styles.summaryValue} transition={GROW}
variants={layoutId ? undefined : faceFade} initial={layoutId ? undefined : "hidden"} animate={layoutId ? undefined : "shown"} exit={layoutId ? undefined : "gone"}>
<Rolling text={shownText} direction={shownDirection} reduced={reduced} />
</motion.span>
<motion.span className={styles.summaryCount} variants={reduced ? faceFade : faceIn} initial="hidden" animate="shown" exit="gone">
{countText && <Rolling text={anchor && sameDay(anchor, hover) ? "끝나는 날을 골라 주세요" : countText} direction={shownDirection} reduced={reduced} />}
</motion.span>
</div>
<motion.div className={styles.actions} variants={reduced ? faceFade : faceIn} initial="hidden" animate="shown" exit="gone">
<button type="button" className={styles.ghost} onClick={() => close(true)}>취소하기</button>
<button type="button" className={styles.primary} onClick={apply} disabled={!shown}>적용하기</button>
</motion.div>
</div>
<p className={styles.srOnly} aria-live="polite">{status}</p>
</motion.div>}
</AnimatePresence>
</motion.div>
</motion.div>;
}
export default DateRangePicker;