React Native Wheel Picker with Momentum and Snapping
By Native Animation · Updated
A React Native wheel picker turns a continuous drag into a discrete selected value. This tutorial builds a vertical wheel with equal-height rows, UI-thread momentum, and a spring that centers the nearest row after release. One shared offset drives every visual row; React receives the selected option only after settling or an explicit button action.
What you'll build
The wheel shows five row heights inside a clipped window with a highlighted center. Nearby rows fade and tilt to suggest depth. A duration example provides five stable choices, and Previous and Next controls offer the same selection outcome without dragging. The accessible surface exposes one adjustable value rather than every decorative row.
This teaching component uses a short, fixed list. The published catalog wheel is horizontal and measures uneven label widths. The shared principles are continuous offset, momentum, nearest-center snapping, and semantic selection; the geometry is intentionally different.
Prerequisites
The examples use Expo SDK 57, React Native 0.86.3, Reanimated 4.5.1, and react-native-worklets 0.10.1. Gesture examples use Gesture Handler ~2.32.0. Resolve compatible versions with Expo rather than choosing the latest npm release:
npx expo install react-native-reanimated react-native-worklets react-native-gesture-handlerUse bunx expo install if your project uses Bun. The SDK 57 Reanimated documentation explains the automatic Babel setup. Keep gesture screens inside a GestureHandlerRootView as shown in the sample; if your application already provides one, reuse that root. These examples belong in a component file outside the Expo Router route tree. Render the exported example from your screen.
Build the component
Create WheelPicker.tsx. The file includes the reusable component and a small usage example.
import { useCallback, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
cancelAnimation,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withDecay,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
const ROW = 48;
const HEIGHT = ROW * 5;
type RowProps = {
label: string;
index: number;
offset: SharedValue<number>;
reducedMotion: boolean;
};
function WheelRow({ label, index, offset, reducedMotion }: RowProps) {
const style = useAnimatedStyle(() => {
const distance = index * ROW - offset.get();
const proximity = Math.max(0, 1 - Math.abs(distance) / (ROW * 3));
return {
opacity: 0.35 + 0.65 * proximity,
transform: [
{ translateY: distance },
{ perspective: 600 },
{
rotateX: reducedMotion
? '0deg'
: Math.max(-55, Math.min(55, -distance / 3)) + 'deg',
},
{ scale: reducedMotion ? 1 : 0.85 + 0.15 * proximity },
],
};
});
return (
<Animated.View style={[styles.row, style]}>
<Text style={styles.label}>{label}</Text>
</Animated.View>
);
}
export function WheelPicker({
options,
onChange,
}: {
options: string[];
onChange?: (value: string) => void;
}) {
const [selected, setSelected] = useState(0);
const offset = useSharedValue(0);
const origin = useSharedValue(0);
const reducedMotion = useReducedMotion();
const last = Math.max(0, options.length - 1);
const commit = useCallback(
(index: number) => {
setSelected(index);
onChange?.(options[index]);
},
[onChange, options],
);
const settle = (position: number) => {
'worklet';
const index = Math.min(last, Math.max(0, Math.round(position / ROW)));
offset.set(
reducedMotion
? index * ROW
: withSpring(
index * ROW,
{
damping: 24,
stiffness: 260,
overshootClamping: true,
},
(finished) => {
if (finished) scheduleOnRN(commit, index);
},
),
);
if (reducedMotion) scheduleOnRN(commit, index);
};
const pan = Gesture.Pan()
.activeOffsetY([-6, 6])
.failOffsetX([-20, 20])
.onBegin(() => {
cancelAnimation(offset);
origin.set(offset.get());
})
.onUpdate((event) => {
offset.set(
Math.min(last * ROW, Math.max(0, origin.get() - event.translationY)),
);
})
.onEnd((event) => {
if (reducedMotion) {
settle(offset.get());
return;
}
offset.set(
withDecay(
{
velocity: -event.velocityY,
clamp: [0, last * ROW],
deceleration: 0.995,
},
(finished) => {
if (finished) settle(offset.get());
},
),
);
})
.onFinalize((_event, success) => {
if (!success) settle(offset.get());
});
function choose(index: number) {
const next = Math.max(0, Math.min(last, index));
cancelAnimation(offset);
offset.set(
reducedMotion
? next * ROW
: withSpring(next * ROW, { damping: 24, stiffness: 260 }),
);
commit(next);
}
if (!options.length) return <Text>No options available.</Text>;
return (
<View style={styles.root}>
<GestureDetector gesture={pan}>
<Animated.View
accessible
accessibilityRole="adjustable"
accessibilityLabel="Duration"
accessibilityValue={{
min: 1,
max: options.length,
now: selected + 1,
text: options[selected],
}}
accessibilityActions={[
{ name: 'increment', label: 'Next option' },
{ name: 'decrement', label: 'Previous option' },
]}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName === 'increment')
choose(selected + 1);
if (event.nativeEvent.actionName === 'decrement')
choose(selected - 1);
}}
style={styles.window}
>
<View pointerEvents="none" style={styles.selection} />
<View
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={styles.visuals}
>
{options.map((label, index) => (
<WheelRow
key={index}
label={label}
index={index}
offset={offset}
reducedMotion={reducedMotion}
/>
))}
</View>
</Animated.View>
</GestureDetector>
<View style={styles.controls}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Previous option"
disabled={selected === 0}
onPress={() => choose(selected - 1)}
style={styles.button}
>
<Text>Previous</Text>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel="Next option"
disabled={selected === last}
onPress={() => choose(selected + 1)}
style={styles.button}
>
<Text>Next</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
root: { width: '100%', maxWidth: 320, alignSelf: 'center' },
window: {
height: HEIGHT,
overflow: 'hidden',
backgroundColor: '#F7F5FC',
borderRadius: 24,
},
visuals: { flex: 1 },
selection: {
position: 'absolute',
top: (HEIGHT - ROW) / 2,
left: 12,
right: 12,
height: ROW,
backgroundColor: '#E9E1FF',
borderRadius: 12,
},
row: {
position: 'absolute',
top: (HEIGHT - ROW) / 2,
left: 0,
right: 0,
height: ROW,
justifyContent: 'center',
alignItems: 'center',
},
label: { color: '#272132', fontSize: 20 },
controls: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 12,
},
button: { minHeight: 44, justifyContent: 'center', paddingHorizontal: 16 },
});
export function WheelExample() {
return (
<GestureHandlerRootView
style={{ flex: 1, justifyContent: 'center', padding: 24 }}
>
<WheelPicker
options={[
'5 minutes',
'10 minutes',
'15 minutes',
'20 minutes',
'30 minutes',
]}
/>
</GestureHandlerRootView>
);
}Offset and the center row
Each row is forty-eight points tall and the viewport spans five rows. The row's base position is the viewport center minus half a row. Its animated translation is index * ROW - offset. When offset equals an option's index times the row height, that option is exactly centered over the selection capsule.
Use the same row height for layout, dragging, snapping, and visual interpolation. A mismatch between any two of these calculations produces a picker that looks close but never centers correctly. With unequal heights, replace index multiplication with measured row centers; do not keep using fixed arithmetic after introducing dynamic content.
The sample derives opacity and perspective from distance to the center. Rotation is capped at fifty-five degrees so distant rows do not rotate indefinitely. The perspective transform provides depth, while clipping prevents rows from appearing outside the wheel window.
Momentum before snapping
A vertical finger movement has the opposite sign to increasing row offset, so the drag subtracts translationY and release passes negative velocityY to withDecay. The decay is clamped between zero and the last row's center. Once it finishes, settle rounds the final offset divided by row height and clamps that index to the list bounds.
A spring then moves to the exact center. Only a successfully finished spring calls scheduleOnRN(commit, index). This avoids publishing a value for a row that a later gesture interrupted before it settled. The withDecay reference describes velocity and bounds; the Worklets scheduleOnRN reference explains the runtime boundary.
Deceleration is a tuning parameter, not a universal platform constant. The sample's 0.995 makes the interaction easy to inspect. Test short flicks and long throws against your row size and desired travel. Avoid adding a JavaScript interval to simulate momentum: the native animation should own continuous position.
Interrupting a throw
On begin, the pan cancels the current decay or spring and captures the current visual offset. New movement starts from that captured position. This prevents the wheel from jumping back to the last committed index when a user grabs it while it is still moving.
Canceled or failed gestures run the settling path too. A second touch may therefore cancel movement briefly before the new gesture activates, but every ending leaves the offset on a valid row. The horizontal failure range lets a strongly horizontal gesture fail early. Nested vertical scrolling needs additional gesture coordination in the surrounding screen; test that real hierarchy rather than relying on an isolated demo.
The button path cancels ongoing animation, selects a bounded index, and commits immediately. Its spring is only visual. That makes a deliberate button or accessibility action immediately correct semantically, while a drag commits when it settles. Keep this distinction when wiring a form: moving artwork is not proof that a new option has been accepted.
Stable options and form integration
The sample expects its options to remain stable while mounted. A new list with fewer items can invalidate the selected index and an in-flight callback's label. When switching datasets, remount the picker with a dataset key or implement a controlled API that reconciles selection and cancels old animations before changing bounds.
Empty options render a text state, and one option has a zero-length momentum range. For a real form, provide meaningful default selection and send the committed value to your parent through onChange. Keep units in each accessible label, such as “15 minutes,” rather than exposing a bare number with a separate visual caption.
The tutorial does not implement a native platform picker or a date-time system control. For dates, account for month lengths, calendar rules, locale, and dependencies between columns separately from the motion model. A wheel animation cannot validate a date for you.
Accessibility and reduced motion
The gesture surface has the adjustable role, current position, bounds, and selected text. Increment and decrement accessibility actions call the same choose function as the visible Previous and Next buttons. Decorative rows and the center highlight are excluded from assistive technology, preventing several nearby values from being mistaken for separate selected controls.
Reduced motion removes perspective and scale changes, skips inertia, and sets the nearest row directly. The selected value and controls remain available. Test the adjustable action with both platform screen readers; touch exploration is different from visually dragging through the rows.
Fixed-height rows require care with larger fonts. The sample is appropriate for its short labels, but a reusable version should measure its label metrics or accept a larger row height that feeds every calculation. Avoid allowing multiline labels inside a fixed row without checking clipping and target geometry.
Performance and verification
Offset changes run on the UI thread and animated styles derive the row transforms. React updates only the settled index or a button selection. This does not make an unbounded list cheap: the sample mounts every option. For hundreds of values, use a virtualized wheel or a platform picker and keep center calculations consistent with virtualization.
Test releases near half-row boundaries, quick flicks at each end, a new drag during decay, direction changes during the spring, canceled gestures, one option, no options, large text, and button presses during momentum. Verify that the callback agrees with the centered label after settling. Profile on target devices before claiming a particular frame rate.
Catalog version and related guides
The published Wheel Picker measures each horizontal label center, so short and long options align without fixed widths. Its public API supports controlled and uncontrolled values, custom labels, optional curvature, colors, fonts, and imperative next, previous, and select methods. Consult its README for the current platform verification status rather than assuming a device test from a successful export.
The published Rolling Temperature Stepper is a button-based alternative when adjacent numeric choices matter more than momentum. The animated counter guide explains that rolling feedback. Use the circle menu guide for discrete actions arranged in a fan, or the progress and slider guide for a continuous numeric range.
FAQ
How do I snap a wheel picker to an item?
For equal-height rows, round offset divided by row height to the nearest index, clamp it to the options, and animate to that index times the row height. Unequal dimensions require measured centers.
How do I preserve momentum in a React Native picker?
Pass gesture release velocity to withDecay, clamp its range, and snap after decay finishes. Use the same shared offset for both the drag and release animation.
Why does my wheel jump when I touch it again?
The new drag likely starts from the committed index instead of the current animated position. Cancel movement and capture the visual offset when the gesture begins.
Can I use this wheel for hundreds of options?
The sample renders all rows, so keep it to short lists. Use virtualization or an appropriate platform control for a large dataset, and preserve an accessible semantic selection path.