Reel Calendar
A date picker you spin through like a reel of time, pull the header back to telescope from days to months to years, so any date is two taps away instead of twenty clicks.
Most date pickers trap you: to reach a date two years out you click the arrow twenty times. Reel Calendar telescopes instead. Tap the header once and the days recede into a grid of months; tap again and the months pull back into years. Pick a year, then a month, and the reel dollies straight back down to the day, each layer zooming through the last under a soft blur. Arrows still page the current scale, arrow keys walk the grid, and the selected day carries a single accent chip that glides with you.
Installation
Complete the shared Setup first, then copy the component into
components/ui/reel-calendar.tsx.
'use client';import * as React from 'react';import { AnimatePresence, motion, useReducedMotion, type Transition, type Variants,} from 'motion/react';import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const MORPH: Transition = { type: 'spring', duration: 0.5, bounce: 0.16 };const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December',];const MONTHS_SHORT = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',];type View = 'days' | 'months' | 'years';type Kind = 'zoom-out' | 'zoom-in' | 'next' | 'prev' | null;export type ReelCalendarProps = { value?: Date | null; defaultValue?: Date | null; onChange?: (date: Date) => void; min?: Date; max?: Date; accent?: string; placeholder?: string; format?: (date: Date) => string; className?: string;};const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());const sameDay = (a: Date | null, b: Date | null) => !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);const firstOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);function buildMonth(cursor: Date): { date: Date; inMonth: boolean }[] { const first = firstOfMonth(cursor); const startWeekday = first.getDay(); const start = addDays(first, -startWeekday); const out: { date: Date; inMonth: boolean }[] = []; for (let i = 0; i < 42; i++) { const date = addDays(start, i); out.push({ date, inMonth: date.getMonth() === cursor.getMonth() }); } return out;}function beforeMin(d: Date, min?: Date) { return !!min && startOfDay(d).getTime() < startOfDay(min).getTime();}function afterMax(d: Date, max?: Date) { return !!max && startOfDay(d).getTime() > startOfDay(max).getTime();}const variants: Variants = { enter: (k: Kind) => { if (k === 'zoom-out') return { opacity: 0, scale: 1.12, filter: 'blur(6px)' }; if (k === 'zoom-in') return { opacity: 0, scale: 0.9, filter: 'blur(6px)' }; if (k === 'next') return { opacity: 0, x: '32%' }; if (k === 'prev') return { opacity: 0, x: '-32%' }; return { opacity: 0 }; }, center: { opacity: 1, scale: 1, x: 0, filter: 'blur(0px)' }, exit: (k: Kind) => { if (k === 'zoom-out') return { opacity: 0, scale: 0.9, filter: 'blur(6px)' }; if (k === 'zoom-in') return { opacity: 0, scale: 1.12, filter: 'blur(6px)' }; if (k === 'next') return { opacity: 0, x: '-32%' }; if (k === 'prev') return { opacity: 0, x: '32%' }; return { opacity: 0 }; },};export function ReelCalendar({ value: controlled, defaultValue = null, onChange, min, max, accent = '#f0883e', placeholder = 'Pick a date', format, className,}: ReelCalendarProps) { const reduce = useReducedMotion(); const isControlled = controlled !== undefined; const [uncontrolled, setUncontrolled] = React.useState<Date | null>(defaultValue); const selected = isControlled ? controlled ?? null : uncontrolled; const [open, setOpen] = React.useState(false); const [view, setView] = React.useState<View>('days'); const [cursor, setCursor] = React.useState<Date>(() => firstOfMonth(selected ?? new Date()), ); const [focusDate, setFocusDate] = React.useState<Date>(() => selected ?? new Date()); const [kind, setKind] = React.useState<Kind>(null); const rootRef = React.useRef<HTMLDivElement>(null); const gridRef = React.useRef<HTMLDivElement>(null); const today = React.useMemo(() => new Date(), []); const transition: Transition = reduce ? { duration: 0.14, ease: EASE_OUT } : { duration: 0.24, ease: EASE_OUT }; const morph: Transition = reduce ? { duration: 0.14, ease: EASE_OUT } : MORPH; React.useEffect(() => { if (!open) return; setView('days'); setCursor(firstOfMonth(selected ?? today)); setFocusDate(selected ?? today); setKind(null); const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; const onDown = (e: PointerEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) { setOpen(false); } }; window.addEventListener('keydown', onKey); window.addEventListener('pointerdown', onDown); return () => { window.removeEventListener('keydown', onKey); window.removeEventListener('pointerdown', onDown); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); React.useEffect(() => { if (!open || view !== 'days') return; const el = gridRef.current?.querySelector<HTMLElement>('[data-focused="true"]'); el?.focus(); }, [open, view, focusDate, cursor]); const select = (d: Date) => { if (beforeMin(d, min) || afterMax(d, max)) return; const picked = startOfDay(d); if (!isControlled) setUncontrolled(picked); onChange?.(picked); setOpen(false); }; const page = (dir: 1 | -1) => { setKind(dir === 1 ? 'next' : 'prev'); if (view === 'days') setCursor((c) => new Date(c.getFullYear(), c.getMonth() + dir, 1)); else if (view === 'months') setCursor((c) => new Date(c.getFullYear() + dir, c.getMonth(), 1)); else setCursor((c) => new Date(c.getFullYear() + dir * 12, c.getMonth(), 1)); }; const zoomOut = () => { if (view === 'days') { setKind('zoom-out'); setView('months'); } else if (view === 'months') { setKind('zoom-out'); setView('years'); } }; const moveFocus = (deltaDays: number) => { setFocusDate((f) => { const next = addDays(f, deltaDays); if (next.getMonth() !== f.getMonth() || next.getFullYear() !== f.getFullYear()) { setKind(deltaDays > 0 ? 'next' : 'prev'); setCursor(firstOfMonth(next)); } return next; }); }; const onGridKey = (e: React.KeyboardEvent) => { if (view !== 'days') return; switch (e.key) { case 'ArrowLeft': e.preventDefault(); moveFocus(-1); break; case 'ArrowRight': e.preventDefault(); moveFocus(1); break; case 'ArrowUp': e.preventDefault(); moveFocus(-7); break; case 'ArrowDown': e.preventDefault(); moveFocus(7); break; case 'PageUp': e.preventDefault(); page(-1); break; case 'PageDown': e.preventDefault(); page(1); break; case 'Enter': case ' ': e.preventDefault(); select(focusDate); break; default: break; } }; const headerLabel = view === 'days' ? `${MONTHS[cursor.getMonth()]} ${cursor.getFullYear()}` : view === 'months' ? `${cursor.getFullYear()}` : `${yearPageStart(cursor)} – ${yearPageStart(cursor) + 11}`; const display = selected ? format ? format(selected) : `${MONTHS_SHORT[selected.getMonth()]} ${selected.getDate()}, ${selected.getFullYear()}` : placeholder; return ( <div ref={rootRef} style={{ '--cal-accent': accent } as React.CSSProperties} className={cn('relative inline-block text-left', className)} > <button type="button" onClick={() => setOpen((o) => !o)} aria-haspopup="dialog" aria-expanded={open} className={cn( 'group flex h-11 min-w-[200px] items-center gap-2.5 rounded-[12px] px-3', 'bg-white text-neutral-900 dark:bg-[#1e1e1b] dark:text-white', 'shadow-[0_0_0_1px_rgba(27,27,25,0.06)] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.06)]', 'transition-transform duration-150 active:scale-[0.98]', FOCUS, )} > <span className="grid size-6 place-items-center text-neutral-400 dark:text-[#8b8b8b]"> <Calendar className="size-[17px]" strokeWidth={1.75} /> </span> <span className={cn( 'text-[13px] font-medium tracking-[-0.01em] tabular-nums', selected ? 'text-neutral-900 dark:text-white' : 'text-neutral-400 dark:text-[#8b8b8b]', )} > {display} </span> </button> <AnimatePresence> {open && ( <motion.div role="dialog" aria-modal="false" aria-label="Choose date" initial={{ opacity: 0, scale: 0.96, y: -4, filter: 'blur(4px)' }} animate={{ opacity: 1, scale: 1, y: 0, filter: 'blur(0px)' }} exit={{ opacity: 0, scale: 0.97, y: -4, filter: 'blur(3px)', transition: { duration: 0.12, ease: EASE_OUT } }} transition={transition} style={{ transformOrigin: 'top left' }} className={cn( 'absolute left-0 top-[calc(100%+8px)] z-50 w-[288px] overflow-hidden rounded-[14px] p-2', 'bg-white text-neutral-900 dark:bg-[#1e1e1b] dark:text-white', 'shadow-[0_0_0_1px_rgba(27,27,25,0.06),0_12px_32px_-14px_rgba(27,27,25,0.16)]', 'dark:shadow-[0_0_0_1px_rgba(255,255,255,0.06),0_16px_38px_-14px_rgba(0,0,0,0.6)]', )} > <div className="mb-1.5 flex items-center gap-1 px-0.5"> <NavButton dir="prev" onClick={() => page(-1)} /> <div className="relative flex-1 overflow-hidden"> <AnimatePresence mode="popLayout" initial={false} custom={kind}> <motion.button key={headerLabel} type="button" custom={kind} onClick={view === 'years' ? undefined : zoomOut} disabled={view === 'years'} initial={reduce ? { opacity: 0 } : variants.enter as never} animate="center" exit={reduce ? { opacity: 0 } : (variants.exit as never)} variants={reduce ? undefined : variants} transition={transition} className={cn( 'mx-auto block rounded-[8px] px-2.5 py-1 text-[13px] font-semibold tabular-nums tracking-[-0.01em]', 'text-neutral-900 dark:text-white', view !== 'years' && 'transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06]', FOCUS, )} > {headerLabel} </motion.button> </AnimatePresence> </div> <NavButton dir="next" onClick={() => page(1)} /> </div> <div ref={gridRef} onKeyDown={onGridKey} className={cn( 'relative h-[248px] overflow-hidden rounded-[11px] p-1.5', 'bg-black/[0.03] dark:bg-black/20', 'shadow-[inset_0_1px_2px_0_rgba(27,27,25,0.05)] dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]', )} > <AnimatePresence mode="popLayout" initial={false} custom={kind}> <motion.div key={view === 'days' ? `d-${cursor.getFullYear()}-${cursor.getMonth()}` : view === 'months' ? `m-${cursor.getFullYear()}` : `y-${yearPageStart(cursor)}`} custom={kind} variants={reduce ? undefined : variants} initial={reduce ? { opacity: 0 } : 'enter'} animate={reduce ? { opacity: 1 } : 'center'} exit={reduce ? { opacity: 0 } : 'exit'} transition={transition} className="absolute inset-1.5" > {view === 'days' && ( <DaysView cursor={cursor} selected={selected} today={today} focusDate={focusDate} min={min} max={max} onPick={select} onFocusDay={setFocusDate} /> )} {view === 'months' && ( <MonthsView cursor={cursor} selected={selected} today={today} onPick={(m) => { setKind('zoom-in'); setCursor((c) => new Date(c.getFullYear(), m, 1)); setView('days'); }} /> )} {view === 'years' && ( <YearsView cursor={cursor} selected={selected} today={today} onPick={(y) => { setKind('zoom-in'); setCursor((c) => new Date(y, c.getMonth(), 1)); setView('months'); }} /> )} </motion.div> </AnimatePresence> </div> <div className="flex items-center justify-between px-1 pb-0.5 pt-2"> <button type="button" onClick={() => { setKind('zoom-in'); select(today); }} className={cn( 'rounded-[7px] px-1.5 py-0.5 text-[11px] font-medium text-neutral-500 dark:text-[#929292]', 'transition-colors hover:text-neutral-900 dark:hover:text-white', FOCUS, )} > Today </button> {selected && ( <span className="text-[11px] tabular-nums text-neutral-400 dark:text-[#8b8b8b]"> {display} </span> )} </div> </motion.div> )} </AnimatePresence> </div> );}function yearPageStart(cursor: Date) { const y = cursor.getFullYear(); return y - (((y % 12) + 12) % 12);}const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--cal-accent)]';function NavButton({ dir, onClick }: { dir: 'prev' | 'next'; onClick: () => void }) { const Icon = dir === 'prev' ? ChevronLeft : ChevronRight; return ( <motion.button type="button" onClick={onClick} whileTap={{ scale: 0.9 }} aria-label={dir === 'prev' ? 'Previous' : 'Next'} className={cn( 'grid size-8 shrink-0 place-items-center rounded-[9px] text-neutral-500 dark:text-[#929292]', 'transition-colors hover:bg-black/[0.04] hover:text-neutral-900', 'dark:hover:bg-white/[0.06] dark:hover:text-white', FOCUS, )} > <Icon className="size-[18px]" strokeWidth={2} /> </motion.button> );}function DaysView({ cursor, selected, today, focusDate, min, max, onPick, onFocusDay,}: { cursor: Date; selected: Date | null; today: Date; focusDate: Date; min?: Date; max?: Date; onPick: (d: Date) => void; onFocusDay: (d: Date) => void;}) { const cells = React.useMemo(() => buildMonth(cursor), [cursor]); return ( <div className="flex h-full flex-col"> <div className="grid grid-cols-7"> {WEEKDAYS.map((d) => ( <span key={d} className="grid h-6 place-items-center text-[10px] font-medium uppercase tracking-[0.04em] text-neutral-400 dark:text-[#767676]" > {d} </span> ))} </div> <div className="grid flex-1 grid-cols-7 grid-rows-6 gap-0.5"> {cells.map(({ date, inMonth }) => { const isSel = sameDay(date, selected); const isToday = sameDay(date, today); const isFocus = sameDay(date, focusDate); const disabled = beforeMin(date, min) || afterMax(date, max); return ( <button key={date.toISOString()} type="button" data-focused={isFocus} tabIndex={isFocus ? 0 : -1} disabled={disabled} onClick={() => onPick(date)} onMouseEnter={() => onFocusDay(date)} aria-label={`${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`} aria-pressed={isSel} className={cn( 'group relative grid place-items-center rounded-[8px] text-[12.5px] font-medium tabular-nums', 'transition-[color,background-color,box-shadow] duration-150', FOCUS, disabled && 'cursor-not-allowed opacity-30', !disabled && !isSel && 'hover:bg-white dark:hover:bg-[#242424]', !disabled && !isSel && 'hover:shadow-[inset_0_0_0_1px_rgba(27,27,25,0.06)]', !disabled && !isSel && 'dark:hover:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]', inMonth ? 'text-neutral-800 dark:text-neutral-100' : 'text-neutral-300 dark:text-[#5a5a5a]', )} > {isSel && ( <motion.span layoutId="cal-selected" className="absolute inset-0 rounded-[8px]" style={{ backgroundColor: 'var(--cal-accent)', boxShadow: 'inset 0 1px 0 0 rgba(255,255,255,0.35), 0 2px 6px -1px color-mix(in srgb, var(--cal-accent) 55%, transparent)', }} transition={MORPH} /> )} <span className={cn('relative z-10', isSel && 'font-semibold')} style={isSel ? { color: '#141612' } : undefined} > {date.getDate()} </span> {isToday && !isSel && ( <span className="absolute bottom-1 h-1 w-1 rounded-full" style={{ backgroundColor: 'var(--cal-accent)' }} /> )} </button> ); })} </div> </div> );}function MonthsView({ cursor, selected, today, onPick,}: { cursor: Date; selected: Date | null; today: Date; onPick: (m: number) => void;}) { return ( <div className="grid h-full grid-cols-3 grid-rows-4 gap-1"> {MONTHS_SHORT.map((label, m) => { const isSel = !!selected && selected.getFullYear() === cursor.getFullYear() && selected.getMonth() === m; const isNow = today.getFullYear() === cursor.getFullYear() && today.getMonth() === m; return ( <ReelChip key={label} label={label} selected={isSel} now={isNow} onClick={() => onPick(m)} /> ); })} </div> );}function YearsView({ cursor, selected, today, onPick,}: { cursor: Date; selected: Date | null; today: Date; onPick: (y: number) => void;}) { const start = yearPageStart(cursor); return ( <div className="grid h-full grid-cols-3 grid-rows-4 gap-1"> {Array.from({ length: 12 }, (_, i) => start + i).map((y) => { const isSel = !!selected && selected.getFullYear() === y; const isNow = today.getFullYear() === y; return ( <ReelChip key={y} label={String(y)} selected={isSel} now={isNow} onClick={() => onPick(y)} /> ); })} </div> );}function ReelChip({ label, selected, now, onClick,}: { label: string; selected: boolean; now: boolean; onClick: () => void;}) { return ( <motion.button type="button" onClick={onClick} whileTap={{ scale: 0.95 }} aria-pressed={selected} className={cn( 'relative grid place-items-center rounded-[9px] text-[12.5px] font-medium tabular-nums', 'transition-[color,background-color,box-shadow] duration-150', FOCUS, selected ? 'text-[#141612]' : 'text-neutral-700 hover:bg-white dark:text-neutral-200 dark:hover:bg-[#242424]', !selected && 'hover:shadow-[inset_0_0_0_1px_rgba(27,27,25,0.06)] dark:hover:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]', )} style={ selected ? { backgroundColor: 'var(--cal-accent)', boxShadow: 'inset 0 1px 0 0 rgba(255,255,255,0.35), 0 2px 6px -1px color-mix(in srgb, var(--cal-accent) 55%, transparent)', } : undefined } > {label} {now && !selected && ( <span className="absolute bottom-1.5 h-1 w-1 rounded-full" style={{ backgroundColor: 'var(--cal-accent)' }} /> )} </motion.button> );}Usage
Uncontrolled by default; pass value and onChange to control it.
import { ReelCalendar } from '@/components/ui/reel-calendar';
export default function Example() {
return <ReelCalendar defaultValue={new Date()} />;
}Examples
Controlled, with a cyan accent.
Bound the range with min and max for booking flows; out-of-range days fall
back and refuse selection.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | Date | null | - | Controlled selected date. |
defaultValue | Date | null | null | Initial date when uncontrolled. |
onChange | (date: Date) => void | - | Fires when a day is picked. |
min | Date | - | Earliest selectable day. |
max | Date | - | Latest selectable day. |
accent | string | "#f0883e" | Accent for the selected chip and today marker. |
placeholder | string | "Pick a date" | Trigger text when nothing is selected. |
format | (date: Date) => string | - | Custom formatter for the trigger label. |
className | string | - | Forwarded to the wrapper. |
Keyboard: arrow keys walk days, PageUp / PageDown page the month, Enter
selects, Esc closes. Under prefers-reduced-motion the telescope collapses to
a plain opacity crossfade and the selected chip stops gliding, but every scale of
time stays reachable.
Footer
A giant-wordmark SaaS footer with a vivid accent band of tinted dots and a brand wordmark that rises in letter-by-letter and clips at the seam.
Number Input
A stepper split into minus, value, and plus by hairline seams, rolling the value in the direction it changed with press-and-hold auto-repeat.