Guide
How to build an iOS-style wheel picker in React
The iOS picker wheel is a drum of rows that you flick, coast and snap to a value. On the web it is a small 3D trick plus some physics: rows sit on a cylinder made with rotateX and translateZ, a scroll position in rows drives the rotation, and a release velocity decides where it comes to rest. This guide walks through the technique, then shows how to compose the beUI WheelPicker into a working time picker you can install with the shadcn CLI.
How does a 3D wheel picker work?
Every row is placed on the surface of a cylinder. Row i is rotated by minus i times a fixed angle around the X axis and pushed out by the cylinder radius with translateZ. The whole list is then pulled back by the radius and rotated by the angle times the current scroll position. When scroll is 3, row 3 faces you and the rows above and below curve away, which is what produces the drum look.
The angle comes from how many rows you want visible. With five rows, two sit on each side of the center, so each row spans 90 divided by 3, or 30 degrees. The radius follows from the row height: itemHeight divided by tan(angle). That keeps neighbouring rows touching on the curve instead of overlapping or leaving gaps.
Keep a single number for the scroll position, measured in rows rather than pixels. A drag converts pixel movement into rows, a flick adds a projected distance, and snapping is just rounding to the nearest integer. Rows more than a quarter turn from the center are behind the horizon, so hide them to avoid the back of the drum bleeding through.
"use client";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
const ITEM_HEIGHT = 36;
const ROWS_EACH_SIDE = 2; // 5 visible rows
const ANGLE = 90 / (ROWS_EACH_SIDE + 1); // degrees each row spans on the drum
const RADIUS = ITEM_HEIGHT / Math.tan((ANGLE * Math.PI) / 180);
const HEIGHT = Math.round(
2 * RADIUS * Math.sin((ROWS_EACH_SIDE * ANGLE * Math.PI) / 180) + ITEM_HEIGHT,
);
export function Drum({
options,
onChange,
}: {
options: string[];
onChange: (value: string) => void;
}) {
// Scroll position measured in rows, so 2.4 means "between row 2 and 3".
const scroll = useMotionValue(0);
const transform = useTransform(
scroll,
(s) => `translateZ(${-RADIUS}px) rotateX(${ANGLE * s}deg)`,
);
const settle = (velocityRows: number) => {
// Project where the flick would coast, then snap to the nearest row.
const projected = scroll.get() + velocityRows * 0.25;
const target = Math.max(0, Math.min(options.length - 1, Math.round(projected)));
animate(scroll, target, { type: "spring", stiffness: 300, damping: 30 });
onChange(options[target]);
};
return (
<motion.div
className="relative touch-none overflow-hidden"
style={{ height: HEIGHT, perspective: 1000 }}
onPan={(_, info) => scroll.set(scroll.get() - info.delta.y / ITEM_HEIGHT)}
onPanEnd={(_, info) => settle(-info.velocity.y / ITEM_HEIGHT)}
>
<motion.ul
className="absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [transform-style:preserve-3d]"
style={{ transform }}
>
{options.map((label, i) => (
<li
key={label}
className="absolute inset-x-0 flex items-center justify-center [backface-visibility:hidden]"
style={{
top: -ITEM_HEIGHT / 2,
height: ITEM_HEIGHT,
transform: `rotateX(${-ANGLE * i}deg) translateZ(${RADIUS}px)`,
}}
>
{label}
</li>
))}
</motion.ul>
</motion.div>
);
}Adding momentum, snapping and keyboard support
A flick should feel like it coasts. On release, average the pointer velocity over the last few dozen milliseconds of movement instead of the final two samples, because a single noisy frame makes an even flick feel like it caught. Convert that velocity to rows, project how far it would travel under a constant deceleration, round to a row, and glide there. A small overshoot on the settle reads as the spring bounce users expect from iOS.
Mouse wheels need a different model. Firing a fresh animation for every wheel notch stacks interrupted animations and feels laggy. Instead, move the scroll position continuously with the wheel delta and snap once the wheel has been idle for a short moment. Bind the wheel and touchmove listeners natively with passive set to false so you can call preventDefault and stop the page from scrolling behind the drum; React's synthetic wheel and touch handlers are passive.
For keyboard users, make the drum focusable and map ArrowUp and ArrowDown to one row, Home to the first row and End to the last. The from-scratch example above leaves those out to stay short; the beUI WheelPicker includes all of them, plus rubber-banding past the ends and a frame-coalesced drag so touch movement paints once per animation frame.
Install the beUI Wheel Picker
beUI is a free, MIT-licensed motion component library for React, built with Tailwind CSS 4 and Framer Motion. Components are copied into your project as source, so you own the code. The wheel picker installs from the @beui namespace in the shadcn registry and lands at components/motion/wheel-picker.tsx, together with the small helpers it imports.
npx shadcn@latest add @beui/wheel-pickerCompose wheels into a time picker
WheelPicker is one column. A time picker is three of them side by side: hours, minutes and AM or PM. Each wheel takes an options array, a value with onValueChange (or defaultValue for uncontrolled use), visibleCount for how many rows show through the window (odd numbers, default 5) and itemHeight in pixels (default 36). Strip the border and background from each wheel and put a single frame around the group so the columns read as one control.
Show the selected time as text above the wheels. It confirms the choice at a glance and gives you a natural place to format the value. The same pattern builds a date picker: the beUI preview composes month, day and year wheels and clamps the day when a shorter month is chosen.
"use client";
import { useState } from "react";
import { WheelPicker } from "@/components/motion/wheel-picker";
const HOURS = Array.from({ length: 12 }, (_, i) => String(i + 1));
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0"));
const PERIODS = ["AM", "PM"];
export function TimePicker() {
const [hour, setHour] = useState("7");
const [minute, setMinute] = useState("30");
const [period, setPeriod] = useState("AM");
return (
<div className="flex flex-col items-center gap-4">
<p className="text-sm text-muted-foreground">
Alarm at{" "}
<span className="font-medium text-foreground tabular-nums">
{hour}:{minute} {period}
</span>
</p>
<div className="flex items-stretch gap-1 rounded-3xl border border-border bg-background p-2">
<WheelPicker
options={HOURS}
value={hour}
onValueChange={setHour}
aria-label="Hour"
visibleCount={7}
itemHeight={42}
className="w-14 border-0 bg-transparent"
/>
<WheelPicker
options={MINUTES}
value={minute}
onValueChange={setMinute}
aria-label="Minute"
visibleCount={7}
itemHeight={42}
className="w-14 border-0 bg-transparent"
/>
<WheelPicker
options={PERIODS}
value={period}
onValueChange={setPeriod}
aria-label="AM or PM"
visibleCount={7}
itemHeight={42}
className="w-16 border-0 bg-transparent"
/>
</div>
</div>
);
}Labels, values and the optional tick sound
Options can be plain strings or objects with a label and a value, which is useful when the text you show differs from what you store. onValueChange always receives the value string. Set sound to play a short synthesized tick as rows cross the center; it is off by default. disabled dims the wheel and removes it from the tab order.
import { WheelPicker } from "@/components/motion/wheel-picker";
const DURATIONS = [
{ label: "15 min", value: "15" },
{ label: "30 min", value: "30" },
{ label: "1 hour", value: "60" },
{ label: "2 hours", value: "120" },
];
export function DurationPicker() {
return (
<WheelPicker
options={DURATIONS}
defaultValue="30"
onValueChange={(minutes) => console.log(Number(minutes))}
sound
aria-label="Meeting length"
className="w-32"
/>
);
}Accessibility and reduced motion for wheel pickers
The beUI drum is a focusable element with your aria-label and full keyboard control (ArrowUp, ArrowDown, Home, End), and it shows a focus ring on keyboard focus. The 3D rows are decorative copies marked aria-hidden, so give every wheel a clear aria-label such as Hour or Minute, and render the chosen value as visible text near the wheels so everyone can confirm it.
When the operating system asks for reduced motion, useReducedMotion returns true and WheelPicker renders a different control: a flat list of native buttons in a CSS scroll-snap container, with no 3D rotation, no momentum and no drag physics. Selecting a row is a plain click. That is a stronger answer than shortening the animation, because a spinning cylinder is exactly the kind of large movement reduced-motion users are asking to avoid.
- Give each wheel its own aria-label.
- Show the combined value as text, for example 7:30 AM.
- Keep a native time input or a text field as a fallback for very long ranges such as years.
- Test with a keyboard: Tab to each wheel and use the arrow keys.
Components in this guide
Wheel Picker
iOS-style picker wheel: a 3D drum on native momentum scroll that snaps to the nearest notch, with wheel, drag and keyboard control. Composes side by side for date and time pickers, reduced-motion safe.
Arc Picker
Radial text picker with top, bottom, left or right curves, smooth wheel movement and live selection on each scroll step. One persistent bracket frame grows and squeezes around each label. Supports drag momentum, keyboard input, disabled choices, controlled or uncontrolled values, forms and reduced motion.
Date Range Picker
Composable date range picker with root, trigger, popover content, calendar, header, grid, summary, footer, clear and preset parts, plus a shared state hook. Compact inline and dropdown compositions, direct month and year selection, and full keyboard navigation.
FAQ
- How do I make an iOS style picker wheel in React?
- Place each row on a cylinder with rotateX and translateZ, rotate the whole list by a scroll position measured in rows, and snap that position to the nearest integer on release. The beUI WheelPicker packages this with momentum, wheel and keyboard input, and installs with npx shadcn@latest add @beui/wheel-picker.
- How do I build a scroll time picker with hours and minutes?
- Render three WheelPicker components side by side, one each for hours, minutes and AM or PM, each with its own options array, value and onValueChange. Combine the three values into a time string in state and show it as text above the wheels.
- Does the wheel picker work on touch devices?
- Yes. beUI WheelPicker binds native touch listeners that are not passive, so a finger drag drives the drum instead of scrolling the page, and a release velocity carries into a momentum coast that snaps to a row.
- Can I use CSS scroll snap instead of 3D transforms?
- You can, and it is the simplest accessible version: a scroll container with scroll-snap-type and one snap point per row. You lose the curved drum and the tuned momentum. beUI uses exactly that flat scroll-snap list as its reduced-motion rendering.
- Is the beUI wheel picker controlled or uncontrolled?
- Both. Pass value and onValueChange to control it, or defaultValue to let it manage its own state. It also follows external value changes by gliding to the new row unless a drag is in progress.
Updated