litenBeta

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.

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.

Example.tsx
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

PropTypeDefaultDescription
valueDate | null-Controlled selected date.
defaultValueDate | nullnullInitial date when uncontrolled.
onChange(date: Date) => void-Fires when a day is picked.
minDate-Earliest selectable day.
maxDate-Latest selectable day.
accentstring"#f0883e"Accent for the selected chip and today marker.
placeholderstring"Pick a date"Trigger text when nothing is selected.
format(date: Date) => string-Custom formatter for the trigger label.
classNamestring-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.

On this page0%