AniUI

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-confirm

Requires 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.

label
string
"Slide to confirm"
confirmedLabel
string
"Confirmed"
disabled
boolean
false
className
string

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 to confirmedLabel after confirming.
  • disabled is exposed via accessibilityState and 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>
  );
}