Slide to Confirm
Slide-to-pay style confirmation — the thumb springs back unless dragged to the end, then locks with a check icon.
Slide to pay $49.00
Web preview — components render natively on iOS & Android
import { SlideToConfirm } from "@/components/ui/slide-to-confirm";
export function CheckoutScreen() {
return (
<SlideToConfirm
label="Slide to pay $49.00"
confirmedLabel="Payment confirmed"
onConfirm={() => submitPayment()}
/>
);
}Installation#
npx @aniui/cli add slide-to-confirmRequires react-native-gesture-handler and react-native-reanimated — wrap your app root in GestureHandlerRootView.
Usage#
onConfirm fires once when the thumb reaches the end of the track. Releasing below 92% of the travel springs the thumb back — no accidental confirmations.
app/checkout.tsx
import { SlideToConfirm } from "@/components/ui/slide-to-confirm";
export function CheckoutScreen() {
return (
<SlideToConfirm
label="Slide to pay $49.00"
confirmedLabel="Payment confirmed"
onConfirm={() => submitPayment()}
/>
);
}Destructive actions#
Restyle the track with className for destructive confirmations.
Slide to delete account
Web preview — components render natively on iOS & Android
// A deliberate slide gesture is much harder to trigger
// accidentally than a tap — ideal for destructive actions.
<SlideToConfirm
className="bg-destructive/15"
label="Slide to delete account"
confirmedLabel="Account deleted"
onConfirm={() => deleteAccount()}
/>Resetting#
Reset via key
// onConfirm fires exactly once. To arm the control again
// (e.g. after a failed request), remount it with a new key.
const [attempt, setAttempt] = useState(0);
<SlideToConfirm
key={attempt}
label="Slide to confirm"
onConfirm={() =>
process().catch(() => setAttempt((a) => a + 1))
}
/>Props#
PropTypeDefault
onConfirm() => void—Fired once when the thumb reaches the end. Remount via key to reset.
labelstring"Slide to confirm"confirmedLabelstring"Confirmed"disabledbooleanfalseclassNamestring—Also accepts all View props from React Native. Needs lucide-react-native for the chevron and check icons.
Accessibility#
- The track is a single accessible element with
accessibilityRole="button"— its label switches toconfirmedLabelafter confirming. disabledis exposed viaaccessibilityStateand dims the control.- The 48dp thumb meets the minimum touch target size.
Source#
components/ui/slide-to-confirm.tsx
import React, { useState } from "react";
import { View, Text, useColorScheme } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, interpolate, runOnJS } from "react-native-reanimated";
import { ChevronRight, Check } from "lucide-react-native";
import { cn } from "@/lib/utils";
const THUMB = 48;
const PAD = 4;
export interface SlideToConfirmProps extends React.ComponentPropsWithoutRef<typeof View> {
className?: string;
label?: string;
confirmedLabel?: string;
disabled?: boolean;
/** Fired once when the thumb reaches the end. Remount (key) to reset. */
onConfirm: () => void;
}
export function SlideToConfirm({
className, label = "Slide to confirm", confirmedLabel = "Confirmed",
disabled, onConfirm, onLayout, ...props
}: SlideToConfirmProps) {
const [confirmed, setConfirmed] = useState(false);
const [trackWidth, setTrackWidth] = useState(0);
const tx = useSharedValue(0);
const end = Math.max(0, trackWidth - THUMB - PAD * 2);
const dark = useColorScheme() === "dark";
const confirm = () => {
setConfirmed(true);
onConfirm();
};
const gesture = Gesture.Pan()
.enabled(!disabled && !confirmed && end > 0)
.onUpdate((e) => { tx.value = Math.max(0, Math.min(end, e.translationX)); })
.onEnd(() => {
if (tx.value >= end * 0.92) {
tx.value = withTiming(end, { duration: 120 }, () => runOnJS(confirm)());
} else {
tx.value = withSpring(0);
}
});
const thumbStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] }));
// The hint label fades out as the thumb travels across it.
const labelStyle = useAnimatedStyle(() => ({
opacity: interpolate(tx.value, [0, end || 1], [1, 0]),
}));
return (
<GestureDetector gesture={gesture}>
<View
className={cn("h-14 w-full justify-center rounded-full bg-secondary", disabled && "opacity-50", className)}
onLayout={(e) => { setTrackWidth(e.nativeEvent.layout.width); onLayout?.(e); }}
accessible={true}
accessibilityRole="button"
accessibilityLabel={confirmed ? confirmedLabel : label}
accessibilityState={{ disabled: !!disabled }}
{...props}
>
<Animated.View style={labelStyle} className="absolute inset-0 items-center justify-center" pointerEvents="none">
<Text className="text-sm font-medium text-muted-foreground">{label}</Text>
</Animated.View>
{confirmed && (
<View className="absolute inset-0 items-center justify-center" pointerEvents="none">
<Text className="text-sm font-medium text-foreground">{confirmedLabel}</Text>
</View>
)}
<Animated.View
style={thumbStyle}
className="ms-1 h-12 w-12 items-center justify-center rounded-full bg-primary"
>
{confirmed ? (
<Check size={20} color={dark ? "#18181b" : "#fafafa"} strokeWidth={2.5} />
) : (
<ChevronRight size={22} color={dark ? "#18181b" : "#fafafa"} />
)}
</Animated.View>
</View>
</GestureDetector>
);
}