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…

Dependencies
lucide-react
Registry dependencies
utils, use-prefers-reduced-motion
"use client"

import * as React from "react"
import { ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"

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

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 and one
shared Zyeon hook only: no gesture library, no animation library, no virtual list.

Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
  registryDependencies) — the live reduced-motion flag that drops the drum curvature
  and turns every programmatic scroll instant. If rebuilding without it: a
  useSyncExternalStore over matchMedia("(prefers-reduced-motion: reduce)") with a
  "change" listener and a server snapshot of false.
- "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. The event
    fires once per turn: when a flick settles, or at once for a key, tap or
    chevron. Never mid-flick, and mounting never reports a value.
  - 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.
- A finger resting on the wheel emits no scroll events, but it has not let go.
  touchstart / touchend / touchcancel track it: a settle that comes due under the
  finger re-arms itself instead of committing (and instead of jumping the drum to
  the rounded row under the thumb), and the release restarts the 110ms quiet period
  so the snap and any momentum finish first. A paused drag is not a stop. On
  release, count only the remaining touches whose target is inside this scroller:
  TouchEvent.touches lists every finger on the screen, and a thumb on the wheel
  beside it would otherwise hold this one forever.
- 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. On a loop the
  step-off starts from the same row on the middle copy (a hop between identical
  rows while the wheel is at rest, so it cannot be seen), which leaves a row on both
  sides even when a fling reached the very end of the rendered strip; every
  "shortest route" is also kept on the strip, taking the copy one cycle over rather
  than aiming past an end that scrollTo would clamp.
- 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.
- That function lights and reports its row at once and claims the turn. Until the
  smooth scroll arrives, the painter does not relight or tick the rows it passes,
  so aria-activedescendant never bounces back to where the turn started, one key
  press is one haptic tick, and a second press (key auto-repeat, a quick double tap
  on a chevron) walks on from the destination. The settle of that turn does not
  report the row again, so a controlled owner that refused it is asked once; and if
  the browser's smooth scroll stopped short of the row, the settle finishes the
  turn in one instant step. A touch or wheel gesture on the drum cancels the claim.
- The chevrons are 44px, report aria-disabled at a fixed end (where a press does
  nothing at all, not even re-commit the row), 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. That write
  fires a scroll event of its own. The wheel records the offset of every instant
  jump it makes itself (this placement, a settle's re-centre) and lets that event
  paint without arming a settle, so mounting never reports a value and a re-centre
  never reports the same row twice.
- Cleanup: the animation frame, the settle timer and the typeahead timer are all
  cancelled on unmount, and prefers-reduced-motion comes from the shared
  use-prefers-reduced-motion hook, whose subscription ends 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 only
  the active index (and, on a loop, which copy of it is centred). Rows are
  memoised and share one delegated click handler on the list, so a detent
  re-renders the rows whose light changed rather than the whole strip, and a flick
  through sixty values styles about nine nodes a frame. Rows leaving the window are
  parked at opacity 0 so a stale transform can never lean back into view.
- The painter's styling half also runs in a layout effect whenever options,
  variant, the reduced-motion flag, itemHeight or visibleCount change, so a change
  that moves nothing (options arriving into an empty wheel, drum switched to inset)
  still restyles the rows before the frame is shown.
- Only the centred copy of the active row is lit (data-selected, font-semibold, the
  inset pill). On a short loop other copies of it sit inside the window too and
  stay plain; aria-selected stays on the middle copy.
- 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 pinch-zoom on the scroller: the browser never fights the
  vertical drag, a horizontal drag stays available to a JS pager around the wheel,
  and pinch-zoom is kept (pan-y on its own would switch zoom off over the wheel);
  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.
  With no options, the emptyLabel is linked to the listbox by aria-describedby, so
  tabbing onto the empty wheel reads it out.
  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 physics — scroll-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 with no finger on the wheel, so a fling across sixty rows fires one change event rather than sixty, and a drag that merely pauses under the thumb commits nothing. 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