# Picker Wheel

> A React iOS-style picker wheel whose columns spin with momentum and snap on a 3D cylinder.

- Type: React component (Mobile)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add picker-wheel`
- Docs: https://wingo-ui.com/components/picker-wheel

## Interaction

Flick a column with a finger and it spins with the phone's own momentum, rows tilting away on a cylinder and fading toward the edges, then clicks into place on a row; each row that reaches the center ticks under the thumb where the phone can vibrate (Android, iOS 17.4+). The value changes once, when the wheel comes to rest, never per row passed; a wheel that lands on a disabled (sold out) row glides on to the nearest one that is not. With a mouse, scroll the wheel, click-drag it up or down and let go to keep it spinning with inertia, or click any visible row to bring it to the center. Each column is one Tab stop: ArrowUp / ArrowDown move a row, PageUp / PageDown five, Home / End jump to the ends, typing jumps to the next option that starts with the text ("19" then "199" on a year wheel), ArrowLeft / ArrowRight move to the neighbor column. Looping columns wrap around from the last row to the first. PickerWheelField shows the value in a form field: a tap opens a bottom sheet on phones with the wheels, Done on top and Cancel under it (Enter on a wheel works like Done; dragging the sheet away keeps the old value), a click or Enter / Space / Alt+ArrowDown opens a popover on desktop where the wheel applies live and Enter or Done closes it, Escape closes and returns focus to the field; the field is announced with its label and its current value. With reduced motion the rows stay flat and programmatic moves jump instead of gliding.

## Installation

```bash
npx wingo-ui@latest add picker-wheel
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/picker-wheel` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/picker-wheel.tsx`

npm dependencies: `npm i @radix-ui/react-popover motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { PickerWheel, PickerWheelField, pickerRange, useDateWheel } from "@/components/ui/picker-wheel"

const hours = pickerRange(8, 19, { pad: 2 })
const minutes = pickerRange(0, 55, { step: 5, pad: 2 })

export function Appointment() {
  const [time, setTime] = useState(["10", "30"])
  const birthday = useDateWheel({ defaultValue: "1990-05-17", locale: "en-US" })

  return (
    <div className="flex flex-col gap-6">
      <PickerWheel aria-label="Date of birth" {...birthday} />
      <PickerWheel
        aria-label="Appointment time"
        loop
        value={time}
        onValueChange={(value) => setTime(value)}
        columns={[
          { id: "hour", label: "h", options: hours },
          { id: "minute", label: "min", options: minutes },
        ]}
      />
      <PickerWheelField
        label="Model year"
        name="year"
        placeholder="Choose the year"
        options={pickerRange(2026, 1990)}
      />
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `columns?` | `PickerWheelColumnDef[]` |  | The wheels, left to right: { id, options, label?, width?: "auto" \| number, align?: "start" \| "center" \| "end", loop?, showLabel? }. label names the listbox and shows beside the center row ("h", "min"). Required unless options is set. |
| `options?` | `PickerWheelOption[]` |  | Shortcut for a single column. Each option is { value, label, textValue?, disabled? }; textValue feeds type-to-jump and the field text when the label is not plain text. |
| `value?` | `string[]` |  | One value per column, in column order (controlled). A value that is not an option shows the first enabled option and fires nothing until the user moves. |
| `defaultValue?` | `string[]` |  | The starting values while uncontrolled; defaults to the first enabled option of each column. |
| `onValueChange?` | `(value: string[], column: string) => void` |  | Fires once per settle with every column's value and the id of the column that moved, never per row passed. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Row height 36 / 44 / 52px with 15 / 17 / 20px text. md matches the wheels of the time picker's sheet. |
| `visibleRows?` | `number` | `5` | How many rows show (odd, 3 to 9). The wheel is rowHeight x visibleRows tall: 220px at md with 5 rows. |
| `perspective?` | `"ios" \| "subtle" \| "flat"` | `"ios"` | How far rows tilt away from the center: 20 / 10 / 0 degrees per row, capped at 55. Opacity falls to 0.35 either way. |
| `band?` | `boolean` | `true` | The rounded selection band behind the center row, shared by all columns. |
| `fade?` | `boolean` | `true` | Fades the top and bottom rows out with a mask, like the iOS picker. |
| `showColumnLabels?` | `boolean` | `true` | Shows each column's label right after its widest option, beside the center row ("2 h 30 min"). A column's own showLabel wins; date columns from useDateWheel turn it off. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | neutral: a light gray band and foreground text. Other tones tint the band and color the centered row. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone for the band and the centered row. |
| `disabled?` | `boolean` | `false` | Dims the wheel, stops scrolling and removes the columns from the Tab order. |
| `loop?` | `boolean` | `false` | Every column wraps around, so 59 is followed by 00 again. A column's own loop wins; lists shorter than half the window never loop. |
| `haptics?` | `boolean` | `true` | A selection tick each time a new row reaches the center while a finger, a mouse drag or a wheel drives it; never for keys, clicks or outside value changes. |
| `aria-label?` | `string` | `""` | The name of the group of wheels ("Date of birth"); each column is named by its own label. |
| `labels?` | `Partial<PickerWheelLabels>` |  | Strings: empty "No options", and for the field done "Done", cancel "Cancel", placeholder "Select". Defaults in DEFAULT_PICKER_WHEEL_LABELS. |
| `classNames?` | `Partial<Record<"band" \| "column" \| "option" \| "columnLabel", string>>` |  | Extra classes for the band, each column listbox, each option row and the column labels. |
| `className?` | `string` |  | Extra classes for the root, merged last (width, margins, tone var overrides). |

## Parts

### PickerWheelField (component)

The wheel as a form field: everything PickerWheel takes plus label, description, error, required, invalid, name (a hidden input with the values joined by formatValue or ","), placeholder, variant ("soft" | "outline" | "ghost"), radius, responsive (a bottom sheet below 768px, default true), open / defaultOpen / onOpenChange, sheetTitle, container and formatValue(value) for the text in the box. The sheet commits on Done (or Enter on a wheel); the desktop popover applies live and closes on Enter or Done. Either way the wheel gets focus on open and the field gets it back on close. Props: PickerWheelFieldProps.

### PickerWheelColumn (component)

One wheel on its own, with the time picker wheel's shape: options, value / defaultValue / onValueChange(value: string), label, showLabel, align, loop and the appearance props (size, visibleRows, perspective, band, fade, tone, color, haptics, disabled). Props: PickerWheelColumnProps.

### useDateWheel (hook)

Day, month and year columns in the locale's order (ro-RO day / month / year, en-US month / day / year), month names in the locale, days that follow the month (29.02 only in leap years; a day that no longer exists becomes the last one) and years between min and max (ISO dates, default 1900-01-01 to 2100-12-31). Values are local ISO dates ("2026-09-24"). Spread it: <PickerWheel {...useDateWheel({ defaultValue: "1990-05-17", locale: "ro-RO" })} />; date is the current ISO date (not spread). Signature: (options: { value?, defaultValue?, onValueChange?(date: string), locale?, min?, max?, order?, labels? }) => { columns, value, onValueChange, date }.

### pickerRange (function)

Numbered options either way: pickerRange(0, 23, { pad: 2 }) gives "00" to "23"; pickerRange(0, 55, { step: 5, pad: 2 }) gives minutes every 5; format(n) makes the label, disabled(n) blocks a number. Signature: (start: number, end: number, options?: { step?, pad?, format?, disabled? }) => PickerWheelOption[].

### DEFAULT_PICKER_WHEEL_LABELS (constant)

The English defaults of labels, for translating once. Type: PickerWheelLabels.

## Examples

- **Time, 24h with loop**: Hours 08 to 19 and minutes every 5 that wrap around, labeled h / min beside the center row.
- **Quantity**: One large column from 1 to 99 with a running total that updates when the wheel settles.
- **As a form field**: PickerWheelField: a bottom sheet with Done / Cancel on phones, a popover that applies live on desktop.
- **Sizes and sold-out options**: sm / md / lg rows (36 / 44 / 52px). Disabled options are struck through and the wheel glides past them.
- **Perspectives**: ios, subtle and flat tilt, seven visible rows. Reduced motion always renders flat.
- **Duration in a tone**: tone="brand" tints the band and the centered rows; the labels sit right after the widest number.
- **Locale order**: useDateWheel follows the locale: day, month, year in ro-RO and month, day, year in en-US, with the month names of each.

## See also

- [Time Picker](https://wingo-ui.com/components/time-picker) ([Markdown](https://wingo-ui.com/components/time-picker.md))
- [Date Picker](https://wingo-ui.com/components/date-picker) ([Markdown](https://wingo-ui.com/components/date-picker.md))
- [Slide To Confirm](https://wingo-ui.com/components/slide-to-confirm) ([Markdown](https://wingo-ui.com/components/slide-to-confirm.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))

---

Canonical: https://wingo-ui.com/components/picker-wheel
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
