Wheel picker
Free · MITReact Native · ExpoiOS-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.
Installation
$ npx shadcn@latest add https://goodui.dev/native/r/wheel-picker.jsonThen 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
- 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.
- 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
| Prop | Type | Default |
|---|---|---|
| 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`. | boolean | false |
| visibleRows Rows the wheel is tall: 3, 5 or 7. Default 5. Ignored inside a WheelPickerGroup, which sets it for every column. | 3 | 5 | 7 | 5 |
| 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. | boolean | false |
| haptic `false` silences the select haptic that ticks for every row passing the center. | boolean | true |
| 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
| Prop | Type | Default |
|---|---|---|
| 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 | 7 | 5 |
| 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.
Related
Inline month calendar: months slide with the finger, the selection circle travels from day to day and stretches toward the new one, a range sweeps its band in from the start day, and booked days are crossed out. A compact wheel variant picks day, month and year in the locale's order.
A field that opens a bottom sheet list with search, sections and checkmarks that draw themselves in. Single mode closes on a pick; multiple mode shows the picks as chips in the field that grow in and shrink away live behind the sheet.
Two to five views of the same thing with an elastic highlight that stretches toward the new segment, can be dragged, and ticks at every boundary. Includes a billing toggle.
Single and range sliders whose thumbs follow the finger 1:1 and settle onto the nearest step on release, with a value bubble that grows out of the thumb while dragging and optional step ticks that tick.