React Native Slider Button with Drag Confirmation
By Native Animation · Updated
A React Native slider button asks for a deliberate action: drag toward the end, release beyond a threshold, then let the application confirm the result. Unlike a value slider, it does not select a number. This tutorial builds a reusable confirmation control with Reanimated 4, an interruptible return, a pending state, and retry after failure.
The normal Confirm button is an equivalent action for people who cannot drag. Animation does not establish whether an operation succeeded: the promise supplied by the host does.
Setup
Use Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Gesture Handler 3.3:
npx expo install react-native-reanimated react-native-worklets react-native-gesture-handlerSubstitute bunx for npx in a Bun project. Follow Expo's Reanimated setup. Create SlideToConfirm.tsx outside the route directory; reuse your application's GestureHandlerRootView if it already has one.
Build the control
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 { useRef, 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, withSpring,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
type Status = 'idle' | 'pending' | 'complete' | 'error';
export function SlideToConfirm({ onConfirm }: { onConfirm: () => Promise<void> }) {
const [status, setStatus] = useState<Status>('idle');
const [width, setWidth] = useState(0);
const busy = useRef(false);
const progress = useSharedValue(0);
const origin = useSharedValue(0);
const locked = useSharedValue(false);
const reduceMotion = useReducedMotion();
const travel = Math.max(1, width - 56);
const available = status === 'idle' || status === 'error';
async function confirm() {
if (busy.current) return;
busy.current = true;
locked.set(true);
progress.set(1);
setStatus('pending');
try {
await onConfirm();
setStatus('complete');
} catch {
setStatus('error');
progress.set(reduceMotion ? 0 : withSpring(0, { damping: 22, stiffness: 220 }));
locked.set(false);
busy.current = false;
}
}
const pan = Gesture.Pan()
.enabled(available && width > 56)
.activeOffsetX([-6, 6])
.failOffsetY([-16, 16])
.onStart(() => {
if (locked.get()) return;
cancelAnimation(progress);
origin.set(progress.get());
})
.onUpdate((event) => {
if (!locked.get()) {
progress.set(Math.max(0, Math.min(1, origin.get() + event.translationX / travel)));
}
})
.onEnd(() => {
if (locked.get()) return;
if (progress.get() >= 0.85) {
locked.set(true);
progress.set(1);
scheduleOnRN(confirm);
} else {
progress.set(reduceMotion ? 0 : withSpring(0, { damping: 22, stiffness: 220 }));
}
})
.onFinalize((_event, success) => {
if (!success && !locked.get()) {
progress.set(reduceMotion ? 0 : withSpring(0, { damping: 22, stiffness: 220 }));
}
});
const thumbStyle = useAnimatedStyle(() => ({
transform: [{ translateX: progress.get() * travel }],
}));
const fillStyle = useAnimatedStyle(() => ({ width: 28 + progress.get() * travel }));
return (
<View style={styles.control}>
<GestureDetector gesture={pan} touchAction="pan-y">
<Animated.View
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
onLayout={({ nativeEvent }) => setWidth(nativeEvent.layout.width)}
style={styles.track}
>
<Animated.View pointerEvents="none" style={[styles.fill, fillStyle]} />
<Text style={styles.prompt}>Slide to confirm</Text>
<Animated.View pointerEvents="none" style={[styles.thumb, thumbStyle]}>
<Text style={styles.arrow}>→</Text>
</Animated.View>
</Animated.View>
</GestureDetector>
<Text accessibilityLiveRegion="polite" style={styles.status}>
{status === 'pending' ? 'Confirming…' : status === 'complete' ? 'Confirmed' :
status === 'error' ? 'Could not confirm. Try again.' : 'Slide or use the button below.'}
</Text>
<Pressable accessibilityRole="button" accessibilityLabel="Confirm action"
accessibilityState={{ disabled: !available, busy: status === 'pending' }}
disabled={!available} onPress={() => { void confirm(); }} style={styles.button}>
<Text>{status === 'error' ? 'Retry confirmation' : 'Confirm'}</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
control: { width: '100%', maxWidth: 400, alignSelf: 'center', gap: 12, direction: 'ltr' },
track: { height: 56, borderRadius: 28, overflow: 'hidden', backgroundColor: '#E5E1EF',
justifyContent: 'center' },
fill: { position: 'absolute', left: 0, height: 56, backgroundColor: '#C5B4EC' },
prompt: { textAlign: 'center', color: '#292136', paddingHorizontal: 56 },
thumb: { position: 'absolute', left: 0, width: 56, height: 56, borderRadius: 28,
backgroundColor: '#6144AD', alignItems: 'center', justifyContent: 'center' },
arrow: { color: '#FFFFFF', fontSize: 24 },
status: { textAlign: 'center', color: '#292136' },
button: { minHeight: 48, alignItems: 'center', justifyContent: 'center',
borderRadius: 12, borderWidth: 1, borderColor: '#6144AD', paddingHorizontal: 16 },
});
export function ConfirmationExample() {
const [confirmed, setConfirmed] = useState(false);
return (
<GestureHandlerRootView style={{ flex: 1, justifyContent: 'center', padding: 24, gap: 24 }}>
<Text>{confirmed ? 'Choice saved in this example.' : 'Ready to save your choice.'}</Text>
<SlideToConfirm onConfirm={async () => { setConfirmed(true); }} />
</GestureHandlerRootView>
);
}Decide on release, then lock
The track width minus the fifty-six-point thumb is its travel distance. A drag maps translation into zero-to-one progress. Release at or beyond 0.85 requests confirmation; a shorter or canceled gesture springs back. The threshold is distance-based deliberately: a fast accidental flick should not confirm this action. New drags cancel the return spring and begin from its current position.
The UI-thread lock closes before scheduleOnRN schedules the JavaScript callback. A separate ref guards the callback against rapid button presses or overlapping input before React has rendered the pending state. Success keeps the control locked; rejection returns it to an available state. Remount the component with a new operation key when starting a different action.
Connect real application work
The usage example saves local state immediately. In an application, pass an async function that awaits the operation and throws on failure. For fetch, explicitly check response.ok: an HTTP error does not automatically reject the promise. Keep that logic in the host, where authentication, cancellation, persistence, and error details belong.
Disabling this control does not deduplicate requests across screens or devices. Use the operation's own idempotency and authorization rules when required. If the screen can unmount while work is pending, let the host own the request lifecycle; this visual component does not cancel a remote operation.
Accessibility and layout
The decorative drag surface is hidden from assistive technology. The labeled Confirm button provides the same action without requiring a path gesture, and pending, success, or retry text communicates the semantic result. On Android, the polite live region announces status changes; verify announcements with VoiceOver as well. The control's state remains readable even when speech is unavailable.
Reduced Motion removes the return spring. The sample uses left-to-right drag geometry and a compact prompt; mirror geometry for RTL and allow extra height for translated or enlarged text. Test vertical scroll negotiation, widths smaller than the thumb, threshold releases, interrupted returns, repeated confirmations, rejected promises, screen readers, and keyboard activation on web.
What the paid component adds
The published Slide to Pay adds a warm trail, a Skia highlight clipped inside prompt glyphs, and a thumb that expands into a loading ring and drawn check. Its labels, amount, colors, optional success badge, and complete/reset ref methods support a full visual scene.
Its onComplete callback runs after the visual success sequence settles. It is not the async pending/error API implemented above, and the animation itself does not verify a transaction. Choose the source when you want that material and choreography, then connect the host operation with explicit pending and failure behavior. Check the published props and platform status before integrating it.
FAQ
How is a slider button different from a normal slider?
A slider selects a value. A slider button requests one action after a deliberate gesture and needs pending, success, failure, and duplicate-input behavior.
Can someone confirm without dragging?
Yes. The visible Confirm button calls the same guarded function, providing a touch, keyboard, and screen-reader alternative.
When should I show success?
After the host's operation resolves. Reaching the end of an animation establishes only that its visual transition finished.