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…
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 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 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, 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
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.