Mobile

Wheel Picker

An iOS-style drum picker: scroll-snap detents under the thumb, optional wrapping, unlandable rows, and the same turns from the keyboard and two chevrons.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"

export interface WheelPickerOption {
  /** Committed through `onValueChange`. Unique inside `options`. */
  value: string
  /** The row's text. One line: a long label truncates instead of wrapping. */
  label: string
  /** Trailing note one size down — a unit, a timezone, "sold out". */
  hint?: string
  /** Scrollable past, never committable: the settle step walks off it to the nearest enabled row. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/wheel-picker.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "WheelPicker" component — the iOS-style drum
that turns under the thumb and clicks into detents. React + lucide-react only: no
gesture library, no animation library, no virtual list.

Contract
- "use client". forwardRef<HTMLDivElement, WheelPickerProps> extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue">; the rest props spread
  onto the root.
- WheelPickerOption = { value: string; label: string; hint?: string; disabled?: boolean }.
  `hint` is a trailing note one size down (a unit, "sold out"); `disabled` rows are
  scrollable past but never committable.
- Props:
  - options: WheelPickerOption[] — required.
  - value / defaultValue / onValueChange(value, option) — controlled and
    uncontrolled are both supported; uncontrolled falls back to the first enabled
    option, and no change event is fired for that initial resolution.
  - variant: "drum" | "flat" | "inset" = "drum". Geometry, never colour: drum
    curves the rows onto a cylinder under a hairline band, flat fades and shrinks
    them under a filled slab, inset keeps every row legible and stamps the selected
    one.
  - visibleCount = 5 — rows in the window; clamped 3..9 and forced odd, because a
    window without a centre has no detent.
  - itemHeight = 44 — the row height AND the detent pitch; clamped 44..96 because
    every row is a tap target.
  - loop = false — wrap past the ends. Ignored under three options: a two-row loop
    is a stutter, not a wheel.
  - label: string, required — the accessible name of the listbox ("Hour", "Weight
    in kilograms"). Name the unit here, not only in `suffix`.
  - suffix?: ReactNode — static text pinned inside the band for a unit that belongs
    to no row.
  - showSteppers = true — the two chevrons, the visible equivalent of the flick.
  - haptics = false, disabled = false, bottomInset = false, emptyLabel = "No options".
- Every numeric prop goes through one clamp helper that rejects NaN: a NaN
  itemHeight divides the whole wheel by zero.

Behavior — let the browser own the physics
- The wheel is a scroll container: scroll-snap-type: y mandatory, one snap point
  per row (scroll-snap-align: center), and the row list padded by
  (visibleCount - 1) / 2 rows top and bottom so the first and last row can still
  reach the centre. That padding is what makes the maths exact: the resting offset
  of row i is always i * itemHeight, with no measuring. Momentum, the rubber band
  at the ends and the final snap are the platform's own, so the wheel feels like
  every other scroller on the device and behaves correctly on hardware this code
  has never seen.
- The detent is round(scrollTop / itemHeight). Crossing a row boundary is the tick:
  a new active row, a new aria-activedescendant, one optional haptic pulse. Nothing
  calls getBoundingClientRect and nothing drifts.
- Settle = the scroll has been quiet for 110ms. Use an idle timer rather than the
  scrollend event so the component behaves the same on every engine. The value
  commits only then — never mid-flick, so a fling across sixty rows fires
  onValueChange exactly once, with the row it stopped on.
- Disabled rows: on settle the wheel searches outward from where it stopped, trying
  the direction of travel first, and scrolls off onto the nearest enabled row
  before committing. So flicking down into a block of sold-out slots leaves you
  below it, not above it. Arrow keys skip disabled rows outright.
- Controlled refusal: every settle bumps a tick, and an effect compares the current
  scroll offset with the offset of the value the owner still holds. If the owner
  ignored the change, the drum springs back to its value. Nothing is ever left
  showing a row the owner did not accept. That spring-back aims at the nearest copy
  of the held row rather than at the middle copy, so it never hauls a turn that has
  just wrapped back across the whole strip.
- loop renders the option list several times (3 copies for long lists, up to 15 for
  very short ones) and silently re-centres on the middle copy AFTER the wheel has
  settled — never during momentum, where a scrollTop write kills the fling. The
  offsets differ by whole cycles of identical rows, so the jump cannot be seen.
  Only the middle copy carries option semantics; every other copy is aria-hidden
  scenery, so the listbox always exposes exactly options.length options.
- Keyboard, on the tabbable listbox: ArrowUp / ArrowDown one detent, PageUp /
  PageDown visibleCount - 1, Home / End the first / last enabled row, Enter and
  Space confirm the centred row (Space would otherwise page the scroller), and
  printable characters are typeahead over the labels with a 700ms buffer — a single
  character cycles through matches, more characters refine. Every one of these,
  plus a tap on a row and the two chevrons, routes through one "turn to this index
  by the shortest route" function, so a wrap at the end animates forward into the
  next copy instead of rewinding the whole strip.
- The chevrons are 44px, report aria-disabled at a fixed end, and never take the
  native disabled attribute — the browser blurs a node the instant it is disabled,
  so reaching the last row would drop focus onto <body>. Same rule for the whole
  component's `disabled`: the wheel stops scrolling and reports aria-disabled while
  staying focusable and readable.
- Placement without a flash: the initial scroll offset is written in the scroller's
  ref callback (commit phase, before the browser paints), not in an effect, which
  would show one frame of row zero before jumping to the selected year.
- Cleanup: the animation frame, the settle timer and the typeahead timer are all
  cancelled on unmount, and prefers-reduced-motion is subscribed through
  useSyncExternalStore over matchMedia and unsubscribed with the component. No
  state is ever set from inside an effect.

Rendering & styling
- Semantic tokens only. Rows are text-foreground when selected and
  text-muted-foreground otherwise; the band is border-y + bg-muted/30 (drum) or a
  filled rounded-lg bg-muted slab (flat); the inset variant stamps the selected row
  bg-foreground text-background — the highest-priority surface inverts instead of
  taking a colour, and whatever is pinned over that stamped row (the row's hint, the
  suffix) inverts with it to text-background/70 rather than staying muted on top of
  it. Chevrons: border + bg-card + hover:bg-accent /
  hover:text-accent-foreground. focus-visible:ring-2 ring-ring ring-inset on the
  listbox, whose ring would otherwise be clipped by its own overflow.
- One painter owns the moving pixels: a rAF-coalesced function that writes
  style.opacity and style.transform straight to the DOM, and only for the rows that
  can be on screen (the window plus two rows of overdraw). React state holds
  nothing but the active index, so a flick through sixty values styles about nine
  nodes a frame instead of re-rendering a list. Rows leaving the window are parked
  at opacity 0 so a stale transform can never lean back into view.
- The rows carry no JSX style at all — their height comes from a CSS custom
  property set once on the list — which is exactly what lets the painter own their
  inline style without React diffing it away.
- drum geometry: theta = (i - centre) * step, step = 50deg / half;
  radius = itemHeight / (2 * tan(step / 2)); transform =
  perspective(radius * 8) translateY(radius * sin(theta) - (i - centre) * itemHeight)
  rotateX(-theta), opacity = cos(theta). The translateY is what pulls the rows onto
  the cylinder's silhouette — without it they only tilt in place and the wheel reads
  as a stack of cards. Past ~80deg a row is edge-on and is hidden instead of drawn.
- prefers-reduced-motion: the curvature is dropped (rows fade and shrink like the
  flat variant) and every programmatic scroll becomes instant instead of smooth.
  The wheel, the detents, the labels and the commit are untouched — the motion is
  decoration, the picking is the feature.
- touch-action: pan-y on the scroller, so the browser never fights the drag and
  every other gesture (a horizontal pager, a pinch) is handed straight back;
  overscroll-behavior-y: contain, so hitting the end of a fixed wheel never scrolls
  the sheet behind it; the scrollbar is hidden on both engines. No preventDefault
  is called anywhere — the gesture IS the platform's scroll.
- Safe area: bottomInset pads with
  var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) for a drum docked
  to the bottom edge, so the last row clears the home indicator. The custom property
  comes first, with env() as its fallback rather than beside it, so a phone frame in
  a demo can simulate the inset while a real device still gets its own.
- ARIA: role="listbox" + aria-label + tabIndex 0 on the scroller, with
  aria-activedescendant pointing at the centred row; each row is role="option" with
  aria-selected, aria-posinset, aria-setsize, and aria-disabled where it applies.
  Deliberately no live region: the listbox already announces the active option, and
  a second channel would announce every detent twice.

Customization levers
- Feel lives in three numbers: itemHeight (the pitch — 44 is a phone row, 56 reads
  as a big deliberate dial), visibleCount (how much context surrounds the value),
  and the 50deg edge angle (30deg is a barely bent list, 70deg a fairground wheel).
- SETTLE_MS (110) is the "has it stopped" threshold. Raise it if onValueChange is
  expensive; lower it if the commit feels late. It is the only timing number that
  changes when the value lands.
- variant is the presentation axis and nothing else — all three share one contract,
  one state machine and one ARIA shape, so swapping them is safe. Skin further via
  data-variant on the root and data-selected on the centred row.
- suffix carries a unit that is not part of any row; label carries it for screen
  readers. Use hint when the note belongs to one row ("full", "+ 210").
- showSteppers={false} when the sheet around the wheel already has its own control
  row — but never remove both the chevrons and the keyboard path.
- Compose columns by putting two or three side by side (hour / minute / AM-PM),
  each its own listbox with its own label. The component draws no surface of its
  own, so the bands line up into one band across the group.
- To dock it in a bottom sheet: drop it inside your sheet, set bottomInset, and let
  the sheet own Cancel / Done. The wheel commits on settle, so a confirm button is
  optional rather than load-bearing.
- haptics is off by default; turn it on for a picker that is the main event (a
  weight, an alarm) and leave it off for one buried in a form.

Concepts

  • Detent — the wheel has no continuous value: round(scrollTop / itemHeight) is the row under the band, and crossing a boundary is a tick. That single expression is the whole selection model, which is why the component measures nothing, drifts nowhere, and can hand the entire drag to the browser.
  • Scroll-snap as physicsscroll-snap-type: y mandatory plus one snap point per row buys real momentum, real rubber banding at the ends and the platform's own settle for free. A hand-rolled fling would have to be re-tuned per OS; this one is already right on hardware the code has never run on.
  • Settle, then commit — the value lands only after the scroll has been quiet for 110ms, so a fling across sixty rows fires one change event rather than sixty. Everything visible during the flick (the lit row, the haptic tick) is a preview; only the settle is a decision, and only the settle can be refused by a controlled owner.
  • Unlandable rows — a disabled row is scrollable past but never restable: the settle searches outward from where the finger stopped, favouring the direction of travel, and steps off onto the nearest enabled row. Blocking the scroll instead would make a wheel that fights back; letting it rest there would leave a selection nobody can accept.
  • Silent re-centre — a looping wheel is a finite strip of repeated copies. When it settles, the offset is rewritten to the equivalent row in the middle copy: identical pixels, whole cycles apart, so the jump is invisible — and it happens only after the fling is over, because a scrollTop write mid-momentum kills the fling on iOS.
  • Every turn has a key — a wheel that only answers to a thumb is unreachable by half its users, so arrows, Page, Home / End, typeahead, two 44px chevrons and a tap on a row all route through the same "turn to this index" call, and the whole thing is announced as a listbox with aria-activedescendant. Under prefers-reduced-motion the curvature and the smooth scrolls go; the picking does not change at all.

On This Page