UI Library

Wheel date & time picker

iOS-style scroll wheels for month, day, year, hour, minute and AM/PM. Each column snaps to a row, tilts and fades away from the centre, and clamps the day when the month gets shorter. Scroll, drag, click a row, or use the arrow keys.

Formsdatetimepickerwheelioskeyboard
Preview
May
June
July
August
September
October
November
December
1
2
3
4
5
6
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
6
7
8
9
10
11
12
53
54
55
56
57
58
59
AM
PM

October 1, 1995 at 11:58 PM

apps/site/blocks/components/WheelDateTimePicker.tsx
'use client';

import { useEffect, useRef, useState, type KeyboardEvent } from 'react';

const MONTHS = [
  'January', 'February', 'March', 'April', 'May', 'June',
  'July', 'August', 'September', 'October', 'November', 'December',
];
const HOURS = Array.from({ length: 12 }, (_, i) => String(i + 1));
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
const MERIDIEM = ['AM', 'PM'];

/** Row height in px. The wheel shows five rows: the selection and two either side. */
const ROW = 40;
const VISIBLE = 5;

const daysIn = (year: number, month: number) => new Date(year, month + 1, 0).getDate();

export function WheelDateTimePicker({
  // A fixed default, not `new Date()`: the preview is pre-rendered at build
  // time, and a clock-derived value would not match on hydrate.
  defaultValue = new Date(1995, 9, 1, 23, 58),
  fromYear = 1900,
  toYear = 2100,
  mode = 'datetime',
  onChange,
}: {
  defaultValue?: Date;
  fromYear?: number;
  toYear?: number;
  mode?: 'date' | 'time' | 'datetime';
  onChange?: (value: Date) => void;
}) {
  const [value, setValue] = useState(defaultValue);

  const years = Array.from({ length: toYear - fromYear + 1 }, (_, i) => String(fromYear + i));
  const y = value.getFullYear();
  const m = value.getMonth();
  const days = Array.from({ length: daysIn(y, m) }, (_, i) => String(i + 1));
  const h24 = value.getHours();

  function update(next: { y?: number; m?: number; d?: number; h?: number; min?: number }) {
    const ny = next.y ?? y;
    const nm = next.m ?? m;
    // Clamp: moving from Jan 31 to February lands on the 28th/29th, not March.
    const nd = Math.min(next.d ?? value.getDate(), daysIn(ny, nm));
    const d = new Date(ny, nm, nd, next.h ?? h24, next.min ?? value.getMinutes());
    setValue(d);
    onChange?.(d);
  }

  return (
    <div className="flex flex-col items-center gap-4">
      <div className="flex flex-wrap items-center justify-center gap-3">
        {mode !== 'time' ? (
          <div className="flex gap-2">
            <Wheel label="Month" items={MONTHS} index={m} onChange={(i) => update({ m: i })} width="w-36" />
            <Wheel label="Day" items={days} index={value.getDate() - 1} onChange={(i) => update({ d: i + 1 })} width="w-16" />
            <Wheel label="Year" items={years} index={y - fromYear} onChange={(i) => update({ y: fromYear + i })} width="w-24" />
          </div>
        ) : null}
        {mode !== 'date' ? (
          <div className="flex gap-2">
            <Wheel label="Hour" items={HOURS} index={(h24 + 11) % 12} onChange={(i) => update({ h: ((i + 1) % 12) + (h24 >= 12 ? 12 : 0) })} width="w-16" />
            <Wheel label="Minute" items={MINUTES} index={value.getMinutes()} onChange={(i) => update({ min: i })} width="w-16" />
            <Wheel label="AM or PM" items={MERIDIEM} index={h24 >= 12 ? 1 : 0} onChange={(i) => update({ h: (h24 % 12) + i * 12 })} width="w-16" />
          </div>
        ) : null}
      </div>
      <p className="text-sm tabular-nums text-muted-foreground" aria-live="polite">
        {value.toLocaleString('en-US', {
          dateStyle: mode === 'time' ? undefined : 'long',
          timeStyle: mode === 'date' ? undefined : 'short',
        })}
      </p>
    </div>
  );
}

/**
 * One wheel. Native scrolling with `scroll-snap` does the physics — momentum,
 * trackpads and touch all behave the way the platform expects — and the scroll
 * offset drives each row's tilt and fade. The selection commits once scrolling
 * settles, so passing rows do not fire `onChange` on the way.
 */
function Wheel({
  label,
  items,
  index,
  onChange,
  width,
}: {
  label: string;
  items: string[];
  index: number;
  onChange: (index: number) => void;
  width: string;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const [offset, setOffset] = useState(index * ROW);
  const settle = useRef<ReturnType<typeof setTimeout> | null>(null);
  const frame = useRef(0);

  // Follow the value when it changes from outside (a clamped day, a keypress).
  useEffect(() => {
    const el = ref.current;
    if (!el || Math.round(el.scrollTop / ROW) === index) return;
    el.scrollTo({ top: index * ROW, behavior: el.dataset.ready ? 'smooth' : 'instant' });
    el.dataset.ready = '1';
  }, [index]);

  useEffect(() => {
    const el = ref.current;
    if (el) el.dataset.ready = '1';
    return () => {
      if (settle.current) clearTimeout(settle.current);
      cancelAnimationFrame(frame.current);
    };
  }, []);

  function onScroll() {
    const el = ref.current;
    if (!el) return;
    cancelAnimationFrame(frame.current);
    frame.current = requestAnimationFrame(() => setOffset(el.scrollTop));
    if (settle.current) clearTimeout(settle.current);
    settle.current = setTimeout(() => {
      const i = Math.max(0, Math.min(items.length - 1, Math.round(el.scrollTop / ROW)));
      if (i !== index) onChange(i);
    }, 120);
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    const moves: Record<string, number> = {
      ArrowUp: index - 1,
      ArrowDown: index + 1,
      PageUp: index - 5,
      PageDown: index + 5,
      Home: 0,
      End: items.length - 1,
    };
    const next = moves[event.key];
    if (next === undefined) return;
    event.preventDefault();
    onChange(Math.max(0, Math.min(items.length - 1, next)));
  }

  const pad = ((VISIBLE - 1) / 2) * ROW;

  return (
    <div className={`relative ${width} rounded-[20px] border border-border bg-card shadow-sm`}>
      {/* The selection band: two hairlines across the centre row. */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-x-2 border-y border-border"
        style={{ top: pad, height: ROW }}
      />
      <div
        ref={ref}
        role="spinbutton"
        tabIndex={0}
        aria-label={label}
        aria-valuemin={0}
        aria-valuemax={items.length - 1}
        aria-valuenow={index}
        aria-valuetext={items[index]}
        onScroll={onScroll}
        onKeyDown={onKeyDown}
        className="snap-y snap-mandatory overflow-y-scroll overscroll-contain rounded-[20px] outline-none [scrollbar-width:none] focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-scrollbar]:hidden"
        style={{ height: ROW * VISIBLE, perspective: 400, paddingBlock: pad }}
      >
        {items.map((item, i) => {
          const distance = (i * ROW - offset) / ROW;
          const far = Math.abs(distance);
          // Rows well out of view skip the transform work entirely.
          if (far > VISIBLE) return <div key={item} className="snap-center" style={{ height: ROW }} />;
          const selected = far < 0.5;
          return (
            <div
              key={item}
              onClick={() => onChange(i)}
              className={`flex snap-center cursor-pointer select-none items-center justify-center whitespace-nowrap tabular-nums ${
                selected ? 'text-lg font-semibold text-foreground' : 'text-sm text-muted-foreground'
              }`}
              style={{
                height: ROW,
                opacity: Math.max(0.2, 1 - far * 0.3),
                transform: `rotateX(${Math.max(-60, Math.min(60, -distance * 20))}deg)`,
              }}
            >
              {item}
            </div>
          );
        })}
      </div>
    </div>
  );
}

Dependencies

None. This block is self-contained — plain React and Tailwind utility classes. Paste the file and it renders.

Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.