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 $12.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 } 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";
import { useThemeColors } from "@/components/ui/theme-provider";

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 colors = useThemeColors();

  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={colors.primaryForeground} strokeWidth={2.5} />
          ) : (
            <ChevronRight size={22} color={colors.primaryForeground} />
          )}
        </Animated.View>
      </View>
    </GestureDetector>
  );
}