Skip to content
WANCRAFT

F.115Forms & Inputs

Wheel Picker

An iOS-style drum picker: native scroll-snap momentum, a 3D cylinder per column, keyboard and typeahead.

NewReactCSSTailwindadvanced

Overview

Wheel Picker recreates the iOS drum picker with one or more columns, such as hours, minutes and AM/PM. Each column is a native scroll-snap list, so wheel, trackpad and touch momentum come straight from the browser, and mice can also grab and spin. On every scroll frame the rows are placed on a cylinder (translate3d plus rotateX by their distance from the centre) by writing styles directly, without React re-renders. When a column comes to rest it settles on the nearest enabled row and reports the value. Each column is a focusable listbox with aria-activedescendant, arrow, Page, Home and End keys, and typeahead.

01Installation

Method 01 · Registry

CLI verified

Uses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/wheel-picker.tsx and adds its dependencies.

npx shadcn@latest add https://wancraft.my/r/wheel-picker.json

Method 02 · Copy and paste

  1. 1. No extra dependencies — React and Tailwind CSS only.
  2. 2. Copy the source from the Code tab into components/ui/wheel-picker.tsx.
  3. 3. Import it and adjust props.
Dependencies
None
Peer
react ≥ 18, tailwindcss ≥ 4
Added
13 Oct 2026
Updated
—

02Usage

The default configuration. Change properties above to generate a tailored example in the Usage tab.

example.tsx
import { WheelPicker, type WheelPickerColumn } from "@/components/ui/wheel-picker";

export function WheelPickerExample() {
  const pad = (n: number) => String(n).padStart(2, "0");
  const columns: WheelPickerColumn[] = [
    { key: "hour", label: "Hours", options: Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: String(i + 1) })) },
    { key: "minute", label: "Minutes", options: Array.from({ length: 12 }, (_, i) => ({ value: pad(i * 5), label: pad(i * 5) })) },
    { key: "period", label: "AM or PM", options: [{ value: "AM", label: "AM" }, { value: "PM", label: "PM" }] },
  ];

  return (
    <div className="w-72">
      <WheelPicker
        label="Alarm time"
        columns={columns}
        defaultValue={{ hour: "7", minute: "30", period: "AM" }}
      />
    </div>
  );
}

03Props

PropTypeDefaultDescription
columnsWheelPickerColumn[]—{ key, label, options: { value, label, disabled? }[], width?, align? } per wheel.
value / defaultValueRecord<string, string>—Selected option value per column key; controlled or initial.
onChange(value: Record<string, string>, changedKey: string) => void—Called when a column settles on a new option.
labelstring"Picker"Accessible name of the role="group" wrapper.
itemHeightnumber36Row height in px.
visibleCountnumber5Visible rows (odd; even values round up).
curvenumber18Degrees each row turns on the drum; 0 is flat.
accentstring"#FF7448"Selection band edges and focus ring.

04Features

  • 01Native scroll-snap columns: wheel, trackpad and touch momentum from the browser
  • 02Rows placed on a true cylinder (translate3d + rotateX) per frame, without React re-renders
  • 03Mouse grab-to-spin with snap re-enabled on release
  • 04Settles on the nearest enabled row; disabled options are skipped
  • 05ArrowUp/Down, PageUp/Down, Home/End and typeahead per column
  • 06Any number of columns; controlled or uncontrolled
motion · scroll-linkedinput · scrollinput · draginput · keyboardinput · click

05Accessibility

  • Each column is a focusable role="listbox" named by its label, with aria-activedescendant pointing at the selected role="option" (aria-selected). Listbox was chosen over spinbutton because the options are discrete labels, not numbers.
  • The columns sit in a role="group" with an accessible name; Tab moves between columns.
  • Keys: ArrowUp/ArrowDown move one row, PageUp/PageDown a page, Home/End the ends; typing jumps to the next matching label.
  • Disabled rows use aria-disabled, are skipped by keys and struck through visually.
  • prefers-reduced-motion makes keyboard and click moves jump instantly instead of scrolling smoothly.

06Limitations

  • Looping (infinite) wheels are not supported; each column stops at its first and last option.
  • The cylinder is a visual effect on top of a flat scroll list; very long lists (thousands of rows) should be windowed.