Wheel Time Picker

iOS-style time picker made of three scroll-snapping wheels.

Remind me at 09:30 AM

01
02
03
04
05
06
07
08
09
10
11
12
00
05
10
15
20
25
30
35
40
45
50
55
AM
PM

Edit with AIPro

Describe a change to Wheel Time Picker and get the updated code. The live preview above stays as the original.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/wheel-picker.tsx

    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 />;
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.