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