Wheel Time Picker
iOS-style time picker made of three scroll-snapping wheels.
Remind me at 09:30 AM
components/ui/wheel-picker.tsx
"use client";
import { useEffect, useRef, useState } from "react";
const ITEM = 40; // px per row
const VISIBLE = 5;
type WheelProps = { items: string[]; value: number; onChange: (index: number) => void; label: string };
/** One scroll-snapping column. Scroll, drag on touch, or use the arrow keys. */
export function Wheel({ items, value, onChange, label }: WheelProps) {
const ref = useRef<HTMLDivElement>(null);
const settle = useRef<ReturnType<typeof setTimeout> | null>(null);
// Position the wheel on mount and when the value is changed from outside.
useEffect(() => {
const el = ref.current;
if (el && Math.round(el.scrollTop / ITEM) !== value) el.scrollTop = value * ITEM;
}, [value]);
return (
<div
ref={ref}
role="listbox"
aria-label={label}
tabIndex={0}
onScroll={(e) => {
const top = e.currentTarget.scrollTop;
if (settle.current) clearTimeout(settle.current);
settle.current = setTimeout(() => onChange(Math.max(0, Math.min(items.length - 1, Math.round(top / ITEM)))), 80);
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") onChange(Math.min(items.length - 1, value + 1));
else if (e.key === "ArrowUp") onChange(Math.max(0, value - 1));
else return;
e.preventDefault();
}}
className="snap-y snap-mandatory overflow-y-auto overscroll-contain outline-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
style={{ height: ITEM * VISIBLE, paddingBlock: ITEM * Math.floor(VISIBLE / 2) }}
>
{items.map((item, i) => (
<div
key={item}
role="option"
aria-selected={i === value}
onClick={() => onChange(i)}
className={`flex snap-center items-center justify-center px-4 tabular-nums transition-all duration-150 ${i === value ? "text-xl font-semibold text-zinc-900" : Math.abs(i - value) === 1 ? "text-base text-zinc-400" : "text-sm text-zinc-300"}`}
style={{ height: ITEM }}
>
{item}
</div>
))}
</div>
);
}
const HOURS = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, "0"));
const MINUTES = Array.from({ length: 12 }, (_, i) => String(i * 5).padStart(2, "0"));
const PERIODS = ["AM", "PM"];
/** iOS-style time picker made of three wheels. */
export function WheelPicker({ onChange }: { onChange?: (time: string) => void }) {
const [h, setH] = useState(8);
const [m, setM] = useState(6);
const [p, setP] = useState(0);
const time = `${HOURS[h]}:${MINUTES[m]} ${PERIODS[p]}`;
useEffect(() => {
onChange?.(time);
}, [time, onChange]);
return (
<div className="w-64 rounded-3xl border border-zinc-200 bg-white p-5 shadow-sm">
<p className="text-center text-sm text-zinc-500">
Remind me at <strong className="text-zinc-900">{time}</strong>
</p>
<div className="relative mt-3 grid grid-cols-3">
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-xl bg-zinc-100" />
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-10 h-14 bg-gradient-to-b from-white to-transparent" />
<div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-14 bg-gradient-to-t from-white to-transparent" />
<div className="relative">
<Wheel items={HOURS} value={h} onChange={setH} label="Hour" />
</div>
<div className="relative">
<Wheel items={MINUTES} value={m} onChange={setM} label="Minute" />
</div>
<div className="relative">
<Wheel items={PERIODS} value={p} onChange={setP} label="AM or PM" />
</div>
</div>
</div>
);
}
export default function Demo() {
return <WheelPicker />;
}
Edit with AIPro
Describe a change to Wheel Time Picker and get the updated code. The live preview above stays as the original.
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/wheel-picker.tsxcomponents/ui/wheel-picker.tsx"use client"; import { useEffect, useRef, useState } from "react"; const ITEM = 40; // px per row const VISIBLE = 5; type WheelProps = { items: string[]; value: number; onChange: (index: number) => void; label: string }; /** One scroll-snapping column. Scroll, drag on touch, or use the arrow keys. */ export function Wheel({ items, value, onChange, label }: WheelProps) { const ref = useRef<HTMLDivElement>(null); const settle = useRef<ReturnType<typeof setTimeout> | null>(null); // Position the wheel on mount and when the value is changed from outside. useEffect(() => { const el = ref.current; if (el && Math.round(el.scrollTop / ITEM) !== value) el.scrollTop = value * ITEM; }, [value]); return ( <div ref={ref} role="listbox" aria-label={label} tabIndex={0} onScroll={(e) => { const top = e.currentTarget.scrollTop; if (settle.current) clearTimeout(settle.current); settle.current = setTimeout(() => onChange(Math.max(0, Math.min(items.length - 1, Math.round(top / ITEM)))), 80); }} onKeyDown={(e) => { if (e.key === "ArrowDown") onChange(Math.min(items.length - 1, value + 1)); else if (e.key === "ArrowUp") onChange(Math.max(0, value - 1)); else return; e.preventDefault(); }} className="snap-y snap-mandatory overflow-y-auto overscroll-contain outline-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" style={{ height: ITEM * VISIBLE, paddingBlock: ITEM * Math.floor(VISIBLE / 2) }} > {items.map((item, i) => ( <div key={item} role="option" aria-selected={i === value} onClick={() => onChange(i)} className={`flex snap-center items-center justify-center px-4 tabular-nums transition-all duration-150 ${i === value ? "text-xl font-semibold text-zinc-900" : Math.abs(i - value) === 1 ? "text-base text-zinc-400" : "text-sm text-zinc-300"}`} style={{ height: ITEM }} > {item} </div> ))} </div> ); } const HOURS = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, "0")); const MINUTES = Array.from({ length: 12 }, (_, i) => String(i * 5).padStart(2, "0")); const PERIODS = ["AM", "PM"]; /** iOS-style time picker made of three wheels. */ export function WheelPicker({ onChange }: { onChange?: (time: string) => void }) { const [h, setH] = useState(8); const [m, setM] = useState(6); const [p, setP] = useState(0); const time = `${HOURS[h]}:${MINUTES[m]} ${PERIODS[p]}`; useEffect(() => { onChange?.(time); }, [time, onChange]); return ( <div className="w-64 rounded-3xl border border-zinc-200 bg-white p-5 shadow-sm"> <p className="text-center text-sm text-zinc-500"> Remind me at <strong className="text-zinc-900">{time}</strong> </p> <div className="relative mt-3 grid grid-cols-3"> <div aria-hidden className="pointer-events-none absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-xl bg-zinc-100" /> <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-10 h-14 bg-gradient-to-b from-white to-transparent" /> <div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-14 bg-gradient-to-t from-white to-transparent" /> <div className="relative"> <Wheel items={HOURS} value={h} onChange={setH} label="Hour" /> </div> <div className="relative"> <Wheel items={MINUTES} value={m} onChange={setM} label="Minute" /> </div> <div className="relative"> <Wheel items={PERIODS} value={p} onChange={setP} label="AM or PM" /> </div> </div> </div> ); } export default function Demo() { return <WheelPicker />; }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.