# Time Picker Wheel — React Calendar Component

> Hour, minute and AM/PM drums you scroll, flick or drag; rows curve away like a cylinder, snap into the centre band, and arrow keys step each wheel.

- Category: [Calendars & Dates](https://pixelpapa.com/components/calendars.md)
- Tags: time-picker, wheel, scroll, snap, drum
- Dependencies: lucide-react (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/calendars/time-picker-wheel

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/time-picker-wheel.json"
```

The file is written to `components/pixelpapa/time-picker-wheel.tsx`. To install manually, run `npm install lucide-react` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Bell } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { cn } from "@/lib/utils";

const ROW = 40; // Row height in px; the wheel shows five rows.

type WheelProps = { items: string[]; index: number; onChange: (index: number) => void; label: string; className?: string };

/** One scroll-snapping drum. Native scrolling handles touch and trackpads; pointer handlers add mouse dragging. */
export function Wheel({ items, index, onChange, label, className }: WheelProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [pos, setPos] = useState(index); // Fractional row under the centre band.
  const drag = useRef<{ y: number; top: number; moved: boolean } | null>(null);
  const settle = useRef<ReturnType<typeof setTimeout> | null>(null);
  const mounted = useRef(false);
  const last = items.length - 1;

  // Follow the value: jump on mount, glide afterwards.
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el || drag.current) return;
    if (Math.round(el.scrollTop / ROW) !== index) el.scrollTo({ top: index * ROW, behavior: mounted.current ? "smooth" : "auto" });
    mounted.current = true;
  }, [index]);

  useEffect(() => () => void (settle.current && clearTimeout(settle.current)), []);

  function onScroll() {
    const el = ref.current;
    if (!el) return;
    setPos(el.scrollTop / ROW);
    // Report the value once scrolling has come to rest on a row.
    if (settle.current) clearTimeout(settle.current);
    settle.current = setTimeout(() => {
      const i = Math.min(last, Math.max(0, Math.round(el.scrollTop / ROW)));
      if (i !== index && !drag.current) onChange(i);
    }, 110);
  }

  function onPointerDown(e: PointerEvent<HTMLDivElement>) {
    const el = ref.current;
    if (!el || e.pointerType !== "mouse" || e.button !== 0) return;
    el.setPointerCapture(e.pointerId);
    el.style.scrollSnapType = "none";
    drag.current = { y: e.clientY, top: el.scrollTop, moved: false };
  }
  function onPointerMove(e: PointerEvent<HTMLDivElement>) {
    const el = ref.current;
    if (!el || !drag.current) return;
    const dy = e.clientY - drag.current.y;
    if (Math.abs(dy) > 3) drag.current.moved = true;
    el.scrollTop = drag.current.top - dy;
  }
  function onPointerUp(e: PointerEvent<HTMLDivElement>) {
    const el = ref.current;
    if (!el || !drag.current) return;
    const { moved } = drag.current;
    drag.current = null;
    // A click without movement selects the row under the pointer; a drag snaps to the nearest row.
    const rect = el.getBoundingClientRect();
    const clicked = Math.floor((e.clientY - rect.top + el.scrollTop) / ROW) - 2;
    const target = Math.min(last, Math.max(0, moved ? Math.round(el.scrollTop / ROW) : clicked));
    el.scrollTo({ top: target * ROW, behavior: "smooth" });
    setTimeout(() => (el.style.scrollSnapType = ""), 320);
    if (target !== index) onChange(target);
  }

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const moves: Record<string, number> = {
      ArrowUp: index - 1,
      ArrowDown: index + 1,
      PageUp: index - 3,
      PageDown: index + 3,
      Home: 0,
      End: last,
    };
    if (e.key in moves) {
      e.preventDefault();
      onChange(Math.min(last, Math.max(0, moves[e.key])));
    }
  }

  return (
    <div
      ref={ref}
      role="spinbutton"
      tabIndex={0}
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={last}
      aria-valuenow={index}
      aria-valuetext={items[index]}
      onScroll={onScroll}
      onKeyDown={onKeyDown}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerUp}
      className={cn(
        "relative h-[200px] cursor-grab snap-y snap-mandatory overflow-y-auto overscroll-contain rounded-xl outline-offset-2 select-none [scrollbar-width:none] active:cursor-grabbing [&::-webkit-scrollbar]:hidden",
        className,
      )}
    >
      <div style={{ paddingBlock: ROW * 2 }}>
        {items.map((item, i) => {
          const d = Math.max(-3, Math.min(3, i - pos));
          return (
            <div
              key={item}
              className={cn(
                "flex snap-center items-center justify-center text-2xl tabular-nums",
                Math.abs(i - pos) < 0.5 ? "font-semibold text-foreground" : "text-muted-foreground",
              )}
              style={{
                height: ROW,
                opacity: Math.max(0.15, 1 - Math.abs(d) * 0.33),
                transform: `perspective(320px) rotateX(${(-d * 22).toFixed(1)}deg) scale(${(1 - Math.abs(d) * 0.07).toFixed(3)})`,
              }}
            >
              {item}
            </div>
          );
        })}
      </div>
    </div>
  );
}

export type TimeValue = { hour: number; minute: number };

type TimeWheelPickerProps = {
  /** 24-hour time. */
  value?: TimeValue;
  defaultValue?: TimeValue;
  onChange?: (value: TimeValue) => void;
  /** Minutes between entries on the minute wheel. */
  minuteStep?: number;
  className?: string;
};

const HOURS = Array.from({ length: 12 }, (_, i) => String(i + 1));
const PERIODS = ["AM", "PM"];

export function TimeWheelPicker({ value, defaultValue = { hour: 9, minute: 0 }, onChange, minuteStep = 5, className }: TimeWheelPickerProps) {
  const [inner, setInner] = useState(defaultValue);
  const time = value ?? inner;
  const minutes = Array.from({ length: Math.floor(60 / minuteStep) }, (_, i) => String(i * minuteStep).padStart(2, "0"));
  const pm = time.hour >= 12;

  function set(next: TimeValue) {
    if (value === undefined) setInner(next);
    onChange?.(next);
  }

  return (
    <div role="group" aria-label="Time" className={cn("relative flex items-center justify-center gap-1", className)}>
      <span aria-hidden className="absolute inset-x-0 top-1/2 h-11 -translate-y-1/2 rounded-xl bg-muted" />
      <Wheel
        label="Hour"
        className="w-16"
        items={HOURS}
        index={((time.hour + 11) % 12)}
        onChange={(i) => set({ ...time, hour: ((i + 1) % 12) + (pm ? 12 : 0) })}
      />
      <span aria-hidden className="relative -mt-1 text-2xl font-semibold text-foreground">
        :
      </span>
      <Wheel
        label="Minute"
        className="w-16"
        items={minutes}
        index={Math.min(minutes.length - 1, Math.round(time.minute / minuteStep))}
        onChange={(i) => set({ ...time, minute: i * minuteStep })}
      />
      <Wheel
        label="AM or PM"
        className="ml-2 w-16"
        items={PERIODS}
        index={pm ? 1 : 0}
        onChange={(i) => set({ ...time, hour: (time.hour % 12) + i * 12 })}
      />
    </div>
  );
}

const readable = (t: TimeValue) => `${t.hour % 12 || 12}:${String(t.minute).padStart(2, "0")} ${t.hour >= 12 ? "PM" : "AM"}`;

export default function TimePickerWheelDemo() {
  const [time, setTime] = useState<TimeValue>({ hour: 9, minute: 30 });

  return (
    <div className="w-full max-w-xs rounded-2xl border border-border bg-card p-5 shadow-sm">
      <div className="flex items-center gap-3">
        <span className="flex size-9 items-center justify-center rounded-full bg-primary/10 text-primary">
          <Bell className="size-4" />
        </span>
        <div>
          <h3 className="text-sm font-semibold text-card-foreground">Standup reminder</h3>
          <p className="text-xs text-muted-foreground">Every weekday</p>
        </div>
      </div>
      <TimeWheelPicker className="mt-3 px-3" value={time} onChange={setTime} />
      <div className="mt-3 flex items-center justify-between gap-3 border-t border-border pt-4">
        <p className="text-sm text-muted-foreground" aria-live="polite">
          Rings at <span className="font-medium text-card-foreground tabular-nums">{readable(time)}</span>
        </p>
        <button type="button" className="h-9 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground">
          Save
        </button>
      </div>
    </div>
  );
}
```

## AI prompt

```text
Add the "Time Picker Wheel" component from PixelPapa UI to this project.

What it is: Hour, minute and AM/PM drums you scroll, flick or drag; rows curve away like a cylinder, snap into the centre band, and arrow keys step each wheel.
Category: Calendars & Dates. Depends on: lucide-react.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/time-picker-wheel.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/time-picker-wheel.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/time-picker-wheel" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More calendars & dates

- [Calendar Event Dots](https://pixelpapa.com/components/calendars/calendar-event-dots.md) (Pro): A month grid with coloured dots for each day's events beside an agenda that re-staggers for the selected day; months slide and arrow keys move the selection.
- [Calendar Heatmap Year](https://pixelpapa.com/components/calendars/calendar-heatmap-year.md): A contribution-style year heatmap: weeks ripple in column by column, and one tooltip springs from cell to cell under the pointer or the arrow keys.
- [Calendar Month Basic](https://pixelpapa.com/components/calendars/calendar-month-basic.md) (Pro): A single-month calendar where months slide in from the direction you navigate, the selection pill glides between days, and the grid is fully arrow-key driven.
- [Calendar Range Picker](https://pixelpapa.com/components/calendars/calendar-range-picker.md) (Pro): Two months side by side for picking a date range: click a start, and a soft band previews the span under your cursor until the second click locks it in.
- [Calendar Week Strip](https://pixelpapa.com/components/calendars/calendar-week-strip.md): A one-week day strip with a selection pill that slides between days; weeks swipe in from the side you navigate and arrow keys roll over into the next week.
- [Date Picker Popover](https://pixelpapa.com/components/calendars/date-picker-popover.md) (Pro): A date field you can type into or open as a popover with quick presets and a sliding month grid; picking a day springs the popover shut and fills the field.
