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.
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils, use-prefers-reduced-motion
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/wheel-picker.jsonPrompt
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 mandatoryplus 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
scrollTopwrite 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
listboxwitharia-activedescendant. Underprefers-reduced-motionthe curvature and the smooth scrolls go; the picking does not change at all.
Keyboard Avoider
A screen container that keeps its content and pinned footer clear of the software keyboard by the height the visual viewport actually reports — resize the frame, lift the frame, or ride only the bar.
Finger Draw Annotate
Mark up a photo with a finger — pen, arrow and highlighter in a thumb-reachable dock, per-mark undo, a caret and arrow keys as the equal non-touch path, and a flattened PNG export.