Wheel picker

Free · MITReact Native · Expo

iOS-style wheel: rows sit on a cylinder in 3D perspective, follow the finger 1:1, keep the fling's momentum and settle exactly on a row with a select tick for every row that passes. Compose several into one picker with WheelPickerGroup. On the web it scrolls with the mouse wheel and drags with the mouse.

View as Markdown
Try it on your phone
Scan to open this demo in your phone's browser and use it with your fingers.

Installation

$ npx shadcn@latest add https://goodui.dev/native/r/wheel-picker.json

Then run npx expo install --fix so react-native-gesture-handler, react-native-reanimated match your Expo SDK. Files land in components/goodui-native/wheel-picker.

Usage

import { useState } from "react";
import { WheelPicker, WheelPickerGroup } from "@/components/goodui-native/wheel-picker/wheel-picker";

const hours = Array.from({ length: 24 }, (_, h) => ({ value: h, label: String(h).padStart(2, "0") }));
const minutes = Array.from({ length: 12 }, (_, i) => ({ value: i * 5, label: String(i * 5).padStart(2, "0") }));

export function DepartureTime() {
  const [hour, setHour] = useState(8);
  const [minute, setMinute] = useState(30);
  return (
    <WheelPickerGroup accessibilityLabel="Departure time">
      <WheelPicker options={hours} value={hour} onValueChange={setHour} loop align="right" accessibilityLabel="Hour" />
      <WheelPicker options={minutes} value={minute} onValueChange={setMinute} loop align="left" accessibilityLabel="Minute" />
    </WheelPickerGroup>
  );
}

When to use

Use it for
  • Picking from an ordered list of values people scroll through: hours and minutes, a duration, a quantity, a year.
  • Several related values chosen together (hours, minutes, AM/PM): put one WheelPicker per column in a WheelPickerGroup.
  • Values that wrap around, like minutes on a clock: set loop.
Reach for something else
  • Two to five options visible at once: use segmented-control or a ChipGroup from chip.
  • Long lists that need search or descriptions: use select.
  • A calendar date where the weekday matters: use date-picker (its wheel variant is built on this).
  • A number in a continuous range: use slider or quantity-stepper.

Examples

One wheel with a disabled row

<WheelPicker
  accessibilityLabel="Guests"
  defaultValue={2}
  options={[1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n} guests`, disabled: n === 5 }))}
  onValueChange={setGuests}
  style={{ width: 160 }}
/>

Duration with fixed unit columns

<WheelPickerGroup size="sm" visibleRows={3}>
  <WheelPicker options={hourOptions} value={h} onValueChange={setH} accessibilityLabel="Hours" />
  <WheelPicker options={[{ value: "h", label: "hours" }]} value="h" accessibilityLabel="Unit" />
  <WheelPicker options={minuteOptions} value={m} onValueChange={setM} loop accessibilityLabel="Minutes" />
</WheelPickerGroup>

API reference

WheelPicker

PropTypeDefault
options*
The rows, top to bottom.
WheelPickerOption<T>[]—
value
Selected value (controlled). Pair it with `onValueChange`.
T—
defaultValue
Starting value when uncontrolled. Defaults to the first enabled option.
T—
onValueChange
Called once the wheel comes to rest on a new row (after a drag, a fling, a tap, the mouse wheel or a key).
(value: T) => void—
loop
The last row is followed by the first again, like minutes on a clock. Default `false`.
booleanfalse
visibleRows
Rows the wheel is tall: 3, 5 or 7. Default 5. Ignored inside a WheelPickerGroup, which sets it for every column.
3 | 5 | 75
size
Row height 32 (`sm`) or 36 pt (`md`). Ignored inside a WheelPickerGroup.
"sm" | "md""md"
align
Text alignment in the column. Multi-column pickers align the outer columns toward the middle.
"left" | "center" | "right""center"
disabled
Dims the control and ignores presses.
booleanfalse
haptic
`false` silences the select haptic that ticks for every row passing the center.
booleantrue
accessibilityLabel
Screen reader name of the wheel, for example "Hour".
string—
labels
Overrides for the built-in strings; a GooduiLocaleProvider pack sets them app-wide.
Partial<WheelPickerLabels>—
style
Style for the outer container.
StyleProp<ViewStyle>—
testID
Identifier for end-to-end tests.
string—

WheelPickerGroup

PropTypeDefault
children*
WheelPicker columns. Each takes `flex: 1` unless its style says otherwise.
ReactNode—
visibleRows
Rows every column is tall: 3, 5 or 7. Default 5.
3 | 5 | 75
size
Row height for every column: 32 (`sm`) or 36 pt (`md`).
"sm" | "md""md"
accessibilityLabel
Names the whole picker for screen readers, for example "Departure time".
string—
style
Style for the outer container.
StyleProp<ViewStyle>—
testID
Identifier for end-to-end tests.
string—

Motion and haptics

  • Rows are placed on a cylinder as tall as the wheel: a row's angle is its arc distance from the center, it moves to r·sin(angle) and tilts by that angle in perspective, fading with cos(angle). The center row is in the foreground color and rows turn secondary as they leave it.
  • Dragging moves the wheel 1:1 with the finger; past either end (without loop) it rubber-bands with growing resistance.
  • Release projects the fling 0.3 s ahead, picks the nearest enabled row and glides there on an ease-out curve whose starting speed matches the finger, so it decelerates naturally. Slow releases and taps settle on the smooth spring.
  • Tapping a row above or below the center turns the wheel to it.
  • Value changes from outside turn the wheel on the smooth spring (to the nearest copy of the row when looping).
  • Reduced motion: release and taps settle with a 120 ms timing without the long glide, and outside changes jump. The 3D layout itself is static and stays.
  • Haptic: select for every row that passes the center while the person drags, flings, taps, scrolls or uses keys; changes made from outside are silent.
  • Haptic: haptic={false} silences a wheel.
  • Position, perspective and colors run on the UI thread; onValueChange fires once when the wheel comes to rest, never per frame.
  • Up to 48 options mount every row. Longer lists (years, for example) mount only a window of rows around the center that moves every six rows.
  • Callers may rebuild options every render: the gesture is only recreated when the pattern of disabled rows changes.

Accessibility

  • Each wheel is one adjustable element: swipe up or down with VoiceOver or TalkBack (increment and decrement actions labelled Next and Previous) to move one row; the value is the row label or its accessibilityLabel.
  • WheelPickerGroup is named with accessibilityLabel and is a group on the web; name every column with its own accessibilityLabel (Hour, Minute).
  • On the web a wheel is a focusable slider with aria-valuenow (row number), aria-valuemin, aria-valuemax and aria-valuetext (the label). Arrow Down or Right moves to the next row, Up or Left to the previous, Page Up and Page Down move five rows, Home and End jump to the first and last enabled row.
  • The drawn rows are decoration: hidden from assistive tech (inert on the web), so only the wheel and its value are read. The selected row is always in the foreground color on the band; the dimmed neighbours are 3D context and do not carry information of their own.
  • Disabled rows are skipped by every input: drag, fling, tap, mouse wheel and keys.
  • The wheel is as tall as its rows (32 or 36 pt each); the whole wheel is the touch target.

Notes for AI

  • Values are strings or numbers. Pass value with onValueChange, or defaultValue alone.
  • onValueChange fires when the wheel stops, not while it spins. Do not debounce it.
  • In a WheelPickerGroup, size and visibleRows come from the group; each column takes flex: 1, set style={{ flex: 0.6 }} to make one narrower. Use align right on a left column and left on a right column so the values meet in the middle.
  • A fixed label column (units) is a WheelPicker with one option.
  • loop needs at least visibleRows options to look continuous.