React Native Ruler Picker: Steps and Snapping
By Native Animation · Updated
A React Native ruler picker turns a drag along a scale into a precise measurement. This tutorial builds a horizontal weight selector with major and minor marks, bounded momentum, nearest-tick snapping, and a controlled value. It keeps movement on the UI thread and sends a selection to React only after settling. The same scale picker can show pounds, kilograms, inches, or centimeters when you supply a matching domain.
Ruler, Slider, or wheel picker?
Use a ruler when spatial distance and neighboring measurements help someone choose a number, such as a weight or height during onboarding. A Slider is simpler for approximate intensity or volume; the SDK 58 native Slider already handles a linear track. A wheel picker suits named choices or a short sequence. A circular slider uses angular travel for a dial. Each pattern still needs bounded values and precise alternatives to dragging.
Map values to displacement
Let i be the tick index and g the spacing in layout points. Then value = min + i × step, offset = i × g, and i = round(offset / g). A fixed center marker selects the mark at offset. Each mark is drawn at viewportWidth / 2 + i × g − offset. Dragging left increases offset, so release velocity must also change sign before entering decay.
The example requires a finite increasing range divisible by a positive step. It validates that contract, clamps out-of-range values, and rounds off-grid controlled values to the nearest tick relative to min. For min = 80, step = 0.5, and value = 150.3, the accepted value is 150.5. Use steps of at least 0.000001; the callback normalizes floating-point noise to six decimal places. Choose another numeric representation if you need finer measurement precision.
Setup for Expo SDK 58
The complete file targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Gesture Handler ~3.3.0. Consult the SDK 58 Reanimated setup and Gesture Handler installation. Expo configures the animation Babel plugin automatically.
npx expo install react-native-reanimated react-native-worklets react-native-gesture-handlerUse bunx instead of npx in Bun projects. Save this as RulerPicker.tsx outside src/app and render RulerExample from a screen. Reuse your existing GestureHandlerRootView if you already have one. The tutorial uses ordinary views and does not require Skia.
Complete controlled measurement picker
The Gesture.* builder API remains available in Gesture Handler 3.3, but is deprecated in favor of its hook API. The example retains the builders.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
cancelAnimation, useAnimatedReaction, useAnimatedStyle, useReducedMotion,
useSharedValue, withDecay, withSpring,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
const GAP = 12, BLOCK = 20;
const SPRING = { damping: 24, stiffness: 260, overshootClamping: true };
type RulerProps = {
value: number;
onChange: (value: number) => void;
min: number;
max: number;
step: number;
unit: string;
accessibilityLabel: string;
onTick?: () => void;
};
function clamp(value: number, min: number, max: number) {
'worklet';
return Math.max(min, Math.min(max, value));
}
export function RulerPicker(props: RulerProps) {
const { min, max, step, value } = props;
const count = Math.round((max - min) / step);
const valid = [min, max, step, value].every(Number.isFinite) && step >= 0.000001 && max > min
&& Number.isSafeInteger(count) && count >= 1 && count <= 1000000
&& Math.abs((max - min) / step - count) < 0.000001;
if (!valid) throw new Error('Use a finite increasing range divisible by a step >= 0.000001 (at most 1,000,000 steps).');
// Remount geometry when the domain changes; stale decay callbacks cannot select in the new domain.
return <RulerTrack key={[min, max, step, props.unit].join(':')} {...props} count={count} />;
}
function RulerTrack({ value, onChange, min, max, step, unit, accessibilityLabel,
onTick, count }: RulerProps & { count: number }) {
const acceptedIndex = clamp(Math.round((value - min) / step), 0, count);
const offset = useSharedValue(acceptedIndex * GAP);
const origin = useSharedValue(0);
const active = useSharedValue(false);
const dragging = useSharedValue(false);
const tickTime = useSharedValue(0);
const epoch = useSharedValue(0);
const reduced = useReducedMotion();
const [width, setWidth] = useState(0);
const [bucket, setBucket] = useState(Math.floor(acceptedIndex / BLOCK));
const [revision, setRevision] = useState(0);
const limit = count * GAP;
const format = (number: number) => String(Number(number.toFixed(6)));
const accepted = min + acceptedIndex * step;
const commit = useCallback((index: number, token: number) => {
if (epoch.get() !== token) return;
onChange(Number((min + index * step).toFixed(6)));
setRevision((previous) => previous + 1);
}, [epoch, min, step, onChange]);
useEffect(() => {
epoch.set(epoch.get() + 1);
active.set(false);
dragging.set(false);
cancelAnimation(offset);
const target = acceptedIndex * GAP;
offset.set(reduced ? target : withSpring(target, SPRING));
return () => {
epoch.set(epoch.get() + 1);
active.set(false);
dragging.set(false);
cancelAnimation(offset);
};
}, [value, acceptedIndex, revision, offset, active, dragging, epoch, reduced]);
useAnimatedReaction(
() => Math.floor(offset.get() / (GAP * BLOCK)),
(next, previous) => {
if (next !== previous) scheduleOnRN(setBucket, next);
},
);
useAnimatedReaction(
() => Math.round(offset.get() / GAP),
(next, previous) => {
if (previous === null || next === previous || !active.get() || !onTick) return;
const now = Date.now();
if (now - tickTime.get() >= 50) {
tickTime.set(now);
scheduleOnRN(onTick);
}
},
[onTick],
);
function settle(token: number) {
'worklet';
const index = clamp(Math.round(offset.get() / GAP), 0, count);
const target = index * GAP;
if (reduced) {
offset.set(target);
active.set(false);
scheduleOnRN(commit, index, token);
} else {
offset.set(withSpring(target, SPRING, (finished) => {
if (finished) {
active.set(false);
scheduleOnRN(commit, index, token);
}
}));
}
}
const pan = Gesture.Pan().activeOffsetX([-6, 6]).failOffsetY([-12, 12])
.onStart(() => {
epoch.set(epoch.get() + 1);
cancelAnimation(offset);
active.set(true);
dragging.set(true);
origin.set(offset.get());
})
.onUpdate((event) => {
if (!dragging.get()) return;
offset.set(clamp(origin.get() - event.translationX, 0, limit));
})
.onEnd((event, success) => {
if (!success || !dragging.get()) return;
dragging.set(false);
const token = epoch.get();
if (reduced) { settle(token); return; }
offset.set(withDecay({ velocity: -event.velocityX, clamp: [0, limit], rubberBandEffect: false },
(finished) => { if (finished) settle(token); }));
})
.onFinalize((_event, success) => {
if (!success && dragging.get()) {
dragging.set(false);
cancelAnimation(offset);
settle(epoch.get());
}
});
const strip = useAnimatedStyle(() => ({ transform: [{ translateX: -offset.get() }] }));
const ticks = useMemo(() => {
// Overscan by two blocks on either side, plus the visible half-width.
const padding = Math.ceil(width / (2 * GAP)) + 2 * BLOCK;
const start = Math.max(0, bucket * BLOCK - padding);
const end = Math.min(count, (bucket + 1) * BLOCK + padding);
return Array.from({ length: end - start + 1 }, (_, index) => start + index);
}, [bucket, count, width]);
function adjust(direction: number) {
epoch.set(epoch.get() + 1);
active.set(false);
dragging.set(false);
cancelAnimation(offset);
commit(clamp(acceptedIndex + direction, 0, count), epoch.get());
}
return (
<View style={styles.root}>
<Text style={styles.value}>{format(accepted)} {unit}</Text>
<GestureDetector gesture={pan}>
<View accessible accessibilityRole="adjustable" accessibilityLabel={accessibilityLabel}
accessibilityValue={{ min, max, now: accepted, text: format(accepted) + ' ' + unit }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={({ nativeEvent }) => {
if (nativeEvent.actionName === 'increment') adjust(1);
if (nativeEvent.actionName === 'decrement') adjust(-1);
}} onLayout={(event) => setWidth(event.nativeEvent.layout.width)} style={styles.track}>
<Animated.View pointerEvents="none" accessible={false} accessibilityElementsHidden
importantForAccessibility="no-hide-descendants" style={[styles.strip, { left: width / 2 }, strip]}>
{ticks.map((index) => (
<View key={index} style={[styles.tickSlot, { left: index * GAP - 30 }]}>
<View style={[styles.tick, { height: index % 10 === 0 ? 36 : index % 5 === 0 ? 26 : 16 }]} />
{index % 10 === 0 && <Text style={styles.tickLabel}>{format(min + index * step)}</Text>}
</View>
))}
</Animated.View>
<View pointerEvents="none" style={styles.marker} />
</View>
</GestureDetector>
<View style={styles.actions}>
<Pressable accessibilityRole="button" accessibilityLabel={'Decrease ' + accessibilityLabel}
disabled={acceptedIndex === 0} accessibilityState={{ disabled: acceptedIndex === 0 }}
onPress={() => adjust(-1)} style={styles.button}><Text>− {format(step)} {unit}</Text></Pressable>
<Pressable accessibilityRole="button" accessibilityLabel={'Increase ' + accessibilityLabel}
disabled={acceptedIndex === count} accessibilityState={{ disabled: acceptedIndex === count }}
onPress={() => adjust(1)} style={styles.button}><Text>+ {format(step)} {unit}</Text></Pressable>
</View>
</View>
);
}
export default function RulerExample() {
const [weight, setWeight] = useState(150);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<View style={styles.screen}>
<RulerPicker value={weight} onChange={setWeight} min={80} max={300} step={1}
unit="lb" accessibilityLabel="Weight in pounds" />
</View>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: '#FFFFFF' },
root: { width: '100%', gap: 20 },
value: { fontSize: 32, color: '#24163D', textAlign: 'center' },
track: { height: 112, overflow: 'hidden', backgroundColor: '#F4F0FA', borderRadius: 16 },
strip: { position: 'absolute', top: 20 },
tickSlot: { position: 'absolute', width: 60, alignItems: 'center' },
tick: { width: 2, backgroundColor: '#675778' },
tickLabel: { marginTop: 8, fontSize: 12, color: '#24163D' },
marker: { position: 'absolute', left: '50%', top: 8, height: 48, width: 2, backgroundColor: '#6236AE' },
actions: { flexDirection: 'row', justifyContent: 'space-between', gap: 12 },
button: { minWidth: 80, minHeight: 48, padding: 12, borderWidth: 1, borderColor: '#675778',
borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
});Draw many marks without mounting the entire range
Minor marks represent one step, medium marks every five steps, and labeled major marks every ten. With step = 0.5, a major mark represents five units rather than ten. The strip translates from one shared offset; individual ticks do not each run their own animation callback.
The mounted window contains only the visible marks plus overscan and one block of twenty ticks. React receives a window update when the offset crosses a twenty-tick block, rather than on every frame. With a 320-point viewport, the upper bound is roughly 130 tick slots whether the domain contains 220 or 100,000 steps. Stable index keys preserve overlapping marks. Tick labels are decorative and hidden from assistive technology; the ruler exposes one meaningful value.
This windowing strategy still needs the JavaScript thread to replenish marks. A long JS stall or a large programmatic jump may temporarily expose an empty section before the next window renders. For strict rendering continuity under those conditions, draw only the visible ticks in a UI-driven Skia path and pool the labels. Avoid allocating one native view for every mark in a large domain. Keep text sparse, and increase label spacing for wider formatted numbers or large text sizes.
Decay, snap, and interruption
A drag cancels the previous animation and records the current offset. Updates clamp continuously to [0, count × GAP]. On successful release, withDecay receives the negated horizontal velocity and the same bounds. When decay finishes, a spring settles onto round(offset / GAP) × GAP. Overshoot clamping prevents the settling spring from leaving the range. Reduced Motion skips both inertia and the spring.
Finalization handles cancellation separately: an interrupted gesture snaps its last position, while a successful release keeps its decay. A new gesture, button action, external value update, or unmount cancels the current animation and advances an interaction token. The RN-thread commit checks that token before accepting a queued result, so a superseded decay cannot overwrite a newer selection. The horizontal activation threshold and vertical failure threshold help a containing vertical scroll view retain vertical gestures; test this relationship in your own scroll hierarchy.
Controlled value, callbacks, and units
The parent owns value and receives onChange after settling or an accessible/button adjustment. It should synchronously store accepted values; persist them to a server separately. The label and accessibilityValue show the accepted value during dragging, while the ruler previews motion. No React state is written for each pointer update. A revision counter reconciles the visual position even when the parent rejects a proposed value and keeps the same prop. External value changes take precedence and interrupt the current gesture or animation.
The optional onTick callback reports rate-limited crossings independently of onChange. It is not a persistence callback. For a live rolling number, add an animated number layer derived from offset rather than calling setState for every frame.
unit is a display label, not a conversion function. For kilograms, supply a kilogram value, bounds, step, and an accessibilityLabel such as Weight in kilograms. For centimeters, do the same with a centimeter domain. When switching units, preserve a canonical physical quantity in the parent (for example kilograms), convert it using 1 lb = 0.45359237 kg or 1 in = 2.54 cm, then round to the new domain's step. Do not merely replace lb with kg. Domain or unit changes remount the track and stop old inertia; this teaching component does not animate between unit systems. Give the same logical measurement a stable canonical value to avoid accumulating conversion-rounding drift.
Optional haptics when crossing ticks
Leave onTick undefined for a silent picker. To add touch feedback, install expo-haptics with npx expo install expo-haptics (bunx in Bun projects) and follow the SDK 58 Haptics documentation. Pass a stable RN-thread callback that calls Haptics.selectionAsync() and handles a rejected promise. No haptics dependency is needed to compile the file above.
The reaction compares rounded tick indices on the UI thread, then uses scheduleOnRN only when a mark changes, an interaction is active, and at least fifty milliseconds have elapsed since the last signal. Fast flicks intentionally skip pulses. Disable the callback when the user turns haptics off, and consider disabling it with Reduced Motion as a product preference. Haptics can be unavailable or suppressed, so they must supplement the visible marker and accessible value.
Accessibility and Reduce Motion
The React Native 0.88 accessibility API supplies adjustable, accessibilityValue, and increment/decrement actions. The two named forty-eight-point buttons offer a precise alternative to dragging and ordinary Tab/activation support on web. The ruler itself is 112 points tall. Buttons disable at the endpoints; actions clamp to the same bounds. There are no arrow-key handlers on the ruler itself.
The numeric label wraps and keeps system text scaling. Test long localized unit names and large accessibility fonts; if labels collide, label fewer major marks or increase GAP. useReducedMotion reads the startup system preference. Subscribe to AccessibilityInfo's reduceMotionChanged event if you need to respond to preference changes while the application remains open. Reduced Motion preserves direct manipulation and selection, removing post-release travel.
What Measure Ruler Picker adds
Measure Ruler Picker turns this pattern into a height onboarding screen: a softly lit track, three levels of marks with a magnifying lens effect, clipped rolling number fields, and an animated imperial/metric redistribution. It adds an interruptible entrance, unit controls, back/continue actions, tap-to-select behavior, adjustable accessibility, and a Reduce Motion path. It uses Skia and expo-linear-gradient alongside the gesture and animation libraries.
Its documented API uses initialValue, minimumValue, and maximumValue in inches (48–84 by default), initialUnit ('imperial' or 'metric'), onValueChange(inches), and onUnitChange(unit). Its value callback can fire as rounded marks change during motion; debounce persistence in the host. Its ref exposes replay(), setValue(inches), and setUnit(unit). It is initialized through props and updated through ref methods; it does not expose the tutorial's controlled value/onChange pair, a generic step prop, weight units, or a haptics prop. Keep the tutorial's generic weight example separate from the catalog component's height API.
Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. Browse value selectors for related numeric inputs.
FAQ
Why does my React Native ruler stop between marks?
Decay produces a continuous offset. After a successful decay, round the offset divided by tick spacing, clamp the index, and animate to that index times spacing. Only commit when the snap completes. Handle canceled gestures too, without accidentally canceling a successful release's inertia.
Can a ruler picker handle decimal measurements?
Yes. A tick is one step, not necessarily one whole unit. Use min + index × step and round relative to min. The example accepts evenly divisible ranges and normalizes callback values to six decimal places; use an integer base unit for requirements beyond that precision.
Does changing the unit label convert the value?
No. Supply converted values, bounds, and steps together, preserving a canonical physical quantity in your application. The paid height picker handles imperial/metric display internally and always reports inches through onValueChange.
Do I need the catalog piece to follow this tutorial?
No. The complete example uses the SDK-compatible gesture and animation dependencies. The catalog piece supplies the finished height-specific art direction, rolling readout, and animated unit transition when you want that presentation.