gestures
Slide Button
A measured swipe-to-confirm control with threshold projection, controlled completion, and optional automatic reset.
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add expo-haptics react-native-gesture-handler react-native-reanimatedanatomy.tsx<SlideButton> <SlideButton.Label /> <SlideButton.OverlayContent /> <SlideButton.Thumb /> <SlideButton.UnderlayContent /></SlideButton>Controlled completion
ControlledCompletionExample.tsximport { useState } from "react";import { Pressable, Text } from "react-native";export function ControlledCompletionExample() { const [completed, setCompleted] = useState(false); return ( <> <SlideButton completed={completed} onCompletedChange={setCompleted} > <> <SlideButton.UnderlayContent> <SlideButton.Label>Slide to verify</SlideButton.Label> </SlideButton.UnderlayContent> <SlideButton.OverlayContent> <SlideButton.Label>Verified</SlideButton.Label> </SlideButton.OverlayContent> <SlideButton.Thumb /> </> </SlideButton> {completed && ( <Pressable onPress={() => setCompleted(false)}> <Text>Reset</Text> </Pressable> )} </> );}Install tailwind variants
pnpm add tailwind-variantsUsage
SlideButtonExample.tsximport { Feather } from "@expo/vector-icons";import { SlideButton } from "@animations/ui/components/gestures/slide-button/uniwind";export function SlideButtonExample() { return ( <SlideButton autoReset autoResetDelay={1800} className="h-12" onComplete={() => console.log("Transfer authorized")} tone="accent" > {({ completed }) => ( <> <SlideButton.UnderlayContent> <SlideButton.Label> Slide to authorize </SlideButton.Label> </SlideButton.UnderlayContent> <SlideButton.OverlayContent> <SlideButton.Label> Transfer authorized </SlideButton.Label> </SlideButton.OverlayContent> <SlideButton.Thumb> <Feather color="#171713" name={completed ? "check" : "arrow-right"} size={20} /> </SlideButton.Thumb> </> )} </SlideButton> );}slide-button/config.tsexport const SLIDE_BUTTON_CONFIG = { autoResetDelay: 1400, completionThreshold: 0.82, inset: 5, spring: { damping: 24, mass: 0.8, stiffness: 260, },} as const;slide-button/types.tsimport type { ReactNode } from "react";import type { SharedValue } from "react-native-reanimated";export type SlideButtonTone = | "accent" | "dark" | "success";export type SlideButtonRenderState = { completed: boolean; disabled: boolean; progress: SharedValue<number>; reset: () => void;};export type SlideButtonSlot = | ReactNode | ((state: SlideButtonRenderState) => ReactNode);export type SlideButtonBaseProps = { accessibilityLabel?: string; autoReset?: boolean; autoResetDelay?: number; children: SlideButtonSlot; completed?: boolean; completionThreshold?: number; defaultCompleted?: boolean; disabled?: boolean; haptics?: boolean; onComplete?: () => void; onCompletedChange?: (completed: boolean) => void; onReset?: () => void; tone?: SlideButtonTone;};slide-button/uniwind/index.tsximport { createContext, type ReactNode, useContext,} from "react";import { Text, View, type TextProps, type ViewProps,} from "react-native";import { GestureDetector } from "react-native-gesture-handler";import Animated, { useAnimatedStyle,} from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import { SLIDE_BUTTON_CONFIG } from "../config";import type { SlideButtonBaseProps, SlideButtonRenderState, SlideButtonSlot, SlideButtonTone,} from "../types";import { useSlideButton } from "../use-slide-button";import { slideButtonVariants, type SlideButtonSlotName,} from "./variants";type SlideButtonController = ReturnType< typeof useSlideButton> & { disabled: boolean; tone: SlideButtonTone;};const SlideButtonContext = createContext<SlideButtonController | null>(null);const SlideButtonLayerContext = createContext< "overlay" | "underlay">("underlay");type SlideButtonPartProps = ViewProps & { children?: ReactNode; className?: string;};type SlideButtonLabelProps = TextProps & { className?: string;};type SlideButtonRootSlotName = Extract< SlideButtonSlotName, "container" | "content">;export type SlideButtonProps = SlideButtonBaseProps & { className?: string; classNames?: SlotsToClasses<SlideButtonRootSlotName>;};function useSlideButtonContext() { const context = useContext(SlideButtonContext); if (!context) { throw new Error( "SlideButton compound components must be used inside SlideButton.", ); } return context;}function renderSlot( children: SlideButtonSlot, state: SlideButtonRenderState,) { return typeof children === "function" ? children(state) : children;}/** Static instruction visible before and beside progress. */function SlideButtonUnderlayContent({ children, className, ...viewProps}: SlideButtonPartProps) { const controller = useSlideButtonContext(); const { underlay } = slideButtonVariants({ layer: "underlay", tone: controller.tone, }); return ( <SlideButtonLayerContext.Provider value="underlay"> <View {...viewProps} className={underlay({ className })} pointerEvents="none" > {children} </View> </SlideButtonLayerContext.Provider> );}/** Confirmation layer progressively revealed as the thumb advances. */function SlideButtonOverlayContent({ children, className, ...viewProps}: SlideButtonPartProps) { const controller = useSlideButtonContext(); const progress = controller.progress; const thumbWidth = controller.thumbWidth; const trackWidth = controller.trackWidth; const travel = controller.travel; const { overlayClip, overlayContent } = slideButtonVariants({ layer: "overlay", tone: controller.tone, }); const clipAnimatedStyle = useAnimatedStyle(() => ({ width: thumbWidth.get() + progress.get() * travel.get(), })); const contentAnimatedStyle = useAnimatedStyle(() => ({ width: Math.max( trackWidth.get() - SLIDE_BUTTON_CONFIG.inset * 2, 0, ), })); return ( <SlideButtonLayerContext.Provider value="overlay"> <Animated.View className={overlayClip()} pointerEvents="none" style={clipAnimatedStyle} > <Animated.View {...viewProps} className={overlayContent({ className })} style={contentAnimatedStyle} > {children} </Animated.View> </Animated.View> </SlideButtonLayerContext.Provider> );}/** Measured draggable handle with consumer-owned content. */function SlideButtonThumb({ children, className, ...viewProps}: SlideButtonPartProps) { const controller = useSlideButtonContext(); const { thumb } = slideButtonVariants({ tone: controller.tone, }); return ( <Animated.View {...viewProps} className={thumb({ className })} onLayout={controller.onThumbLayout} style={controller.thumbAnimatedStyle} > {children} </Animated.View> );}/** Text that automatically inherits the current layer and tone colors. */function SlideButtonLabel({ children, className, ...textProps}: SlideButtonLabelProps) { const controller = useSlideButtonContext(); const layer = useContext(SlideButtonLayerContext); const { label } = slideButtonVariants({ layer, tone: controller.tone, }); return ( <Text {...textProps} className={label({ className })} > {children} </Text> );}function SlideButtonRoot({ accessibilityLabel = "Slide to confirm", autoReset, autoResetDelay, children, className, classNames, completed, completionThreshold, defaultCompleted, disabled = false, haptics, onComplete, onCompletedChange, onReset, tone = "dark",}: SlideButtonProps) { const controller = useSlideButton({ autoReset, autoResetDelay, completed, completionThreshold, defaultCompleted, disabled, haptics, onComplete, onCompletedChange, onReset, }); const { container, content } = slideButtonVariants({ disabled, tone, }); const renderState = { completed: controller.completed, disabled, progress: controller.progress, reset: controller.reset, } satisfies SlideButtonRenderState; function handleAccessibilityTap() { if (!disabled && !controller.completed) { controller.complete(); } } return ( <SlideButtonContext.Provider value={{ ...controller, disabled, tone }} > <GestureDetector gesture={controller.gesture}> <View accessibilityLabel={accessibilityLabel} accessibilityRole="button" accessibilityState={controller.accessibilityState} accessible className={container({ className: [className, classNames?.container], })} onAccessibilityTap={handleAccessibilityTap} onLayout={controller.onTrackLayout} > <View className={content({ className: classNames?.content, })} > {renderSlot(children, renderState)} </View> </View> </GestureDetector> </SlideButtonContext.Provider> );}export const SlideButton = Object.assign( SlideButtonRoot, { Label: SlideButtonLabel, OverlayContent: SlideButtonOverlayContent, Thumb: SlideButtonThumb, UnderlayContent: SlideButtonUnderlayContent, },);slide-button/uniwind/variants.tsimport { tv } from "tailwind-variants";const slideButtonSlots = { container: "h-13.5 w-full overflow-hidden rounded-full bg-[#e9e8e2]", content: "flex-1 overflow-hidden", label: "text-[15px] font-black leading-5 tracking-[-0.2px]", overlayClip: "absolute inset-y-1.25 left-1.25 overflow-hidden rounded-full", overlayContent: "absolute left-0 top-0 h-full items-center justify-center", thumb: "absolute inset-y-1.25 left-1.25 aspect-square items-center justify-center rounded-full bg-white", underlay: "absolute inset-0 items-center justify-center",} as const;export type SlideButtonSlotName = keyof typeof slideButtonSlots;export const slideButtonVariants = tv({ slots: slideButtonSlots, variants: { disabled: { true: { container: "opacity-50", }, }, layer: { overlay: {}, underlay: {}, }, tone: { accent: { label: "text-white", overlayContent: "bg-[#6f5cff]", }, dark: { label: "text-white", overlayContent: "bg-[#171713]", }, success: { label: "text-[#12301f]", overlayContent: "bg-[#61d99b]", }, }, }, compoundVariants: [ { class: { label: "text-[#68675f]", }, layer: "underlay", }, ],});slide-button/use-slide-button.tsimport { useCallback, useMemo, useState,} from "react";import * as Haptics from "expo-haptics";import type { LayoutChangeEvent } from "react-native";import { Gesture } from "react-native-gesture-handler";import { clamp, runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useReducedMotion, useSharedValue, withDelay, withSequence, withSpring, withTiming,} from "react-native-reanimated";import { SLIDE_BUTTON_CONFIG } from "./config";import type { SlideButtonBaseProps } from "./types";type UseSlideButtonOptions = Pick< SlideButtonBaseProps, | "autoReset" | "autoResetDelay" | "completed" | "completionThreshold" | "defaultCompleted" | "disabled" | "haptics" | "onComplete" | "onCompletedChange" | "onReset">;export function useSlideButton({ autoReset = false, autoResetDelay = SLIDE_BUTTON_CONFIG.autoResetDelay, completed, completionThreshold = SLIDE_BUTTON_CONFIG.completionThreshold, defaultCompleted = false, disabled = false, haptics = true, onComplete, onCompletedChange, onReset,}: UseSlideButtonOptions) { const [internalCompleted, setInternalCompleted] = useState( defaultCompleted, ); const resolvedCompleted = completed ?? internalCompleted; const progress = useSharedValue(resolvedCompleted ? 1 : 0); const gestureStart = useSharedValue(0); const ownsTransition = useSharedValue(false); const thumbWidth = useSharedValue(0); const trackWidth = useSharedValue(0); const reducedMotion = useReducedMotion(); const travel = useDerivedValue(() => Math.max( trackWidth.get() - thumbWidth.get() - SLIDE_BUTTON_CONFIG.inset * 2, 0, ), ); const beginResetState = useCallback(() => { if (completed === undefined) { setInternalCompleted(false); } onCompletedChange?.(false); }, [completed, onCompletedChange]); const finishReset = useCallback(() => { onReset?.(); }, [onReset]); const completeState = useCallback(() => { if (completed === undefined) { setInternalCompleted(true); } if (haptics) { void Haptics.notificationAsync( Haptics.NotificationFeedbackType.Success, ); } onCompletedChange?.(true); onComplete?.(); }, [completed, haptics, onComplete, onCompletedChange]); const reset = useCallback(() => { ownsTransition.set(true); progress.set( reducedMotion ? withTiming(0, { duration: 0 }, (finished) => { if (finished) { runOnJS(finishReset)(); } }) : withSpring( 0, SLIDE_BUTTON_CONFIG.spring, (finished) => { if (finished) { runOnJS(finishReset)(); } }, ), ); beginResetState(); }, [ beginResetState, finishReset, ownsTransition, progress, reducedMotion, ]); const complete = useCallback(() => { if (disabled || resolvedCompleted) { return; } ownsTransition.set(true); if (reducedMotion) { progress.set( autoReset ? withSequence( withTiming(1, { duration: 0 }, (finished) => { if (finished) { runOnJS(completeState)(); } }), withDelay( autoResetDelay, withSequence( withTiming(1, { duration: 0 }, (finished) => { if (finished) { ownsTransition.set(true); runOnJS(beginResetState)(); } }), withTiming(0, { duration: 0 }, (finished) => { if (finished) { runOnJS(finishReset)(); } }), ), ), ) : withTiming(1, { duration: 0 }, (finished) => { if (finished) { runOnJS(completeState)(); } }), ); return; } progress.set( autoReset ? withSequence( withSpring( 1, SLIDE_BUTTON_CONFIG.spring, (finished) => { if (finished) { runOnJS(completeState)(); } }, ), withDelay( autoResetDelay, withSequence( withTiming(1, { duration: 0 }, (finished) => { if (finished) { ownsTransition.set(true); runOnJS(beginResetState)(); } }), withSpring( 0, SLIDE_BUTTON_CONFIG.spring, (finished) => { if (finished) { runOnJS(finishReset)(); } }, ), ), ), ) : withSpring( 1, SLIDE_BUTTON_CONFIG.spring, (finished) => { if (finished) { runOnJS(completeState)(); } }, ), ); }, [ autoReset, autoResetDelay, beginResetState, completeState, disabled, finishReset, ownsTransition, progress, reducedMotion, resolvedCompleted, ]); useAnimatedReaction( () => resolvedCompleted, (nextCompleted, previousCompleted) => { const nextProgress = nextCompleted ? 1 : 0; if (previousCompleted === null) { progress.set(nextProgress); return; } if (ownsTransition.get()) { ownsTransition.set(false); return; } progress.set( reducedMotion ? nextProgress : withSpring( nextProgress, SLIDE_BUTTON_CONFIG.spring, ), ); }, ); const gesture = useMemo( () => Gesture.Pan() .enabled(!disabled && !resolvedCompleted) .activeOffsetX([-4, 4]) .failOffsetY([-12, 12]) .onBegin(() => { gestureStart.set(progress.get() * travel.get()); }) .onUpdate((event) => { const availableTravel = travel.get(); if (availableTravel === 0) { return; } progress.set( clamp( (gestureStart.get() + event.translationX) / availableTravel, 0, 1, ), ); }) .onEnd((event) => { const projectedProgress = progress.get() + event.velocityX / Math.max(travel.get(), 1) / 7; const shouldComplete = projectedProgress >= completionThreshold; if (!shouldComplete) { progress.set( reducedMotion ? 0 : withSpring( 0, SLIDE_BUTTON_CONFIG.spring, ), ); return; } runOnJS(complete)(); }), [ complete, completionThreshold, disabled, gestureStart, progress, reducedMotion, resolvedCompleted, travel, ], ); const onTrackLayout = useCallback( (event: LayoutChangeEvent) => { trackWidth.set(event.nativeEvent.layout.width); }, [trackWidth], ); const onThumbLayout = useCallback( (event: LayoutChangeEvent) => { thumbWidth.set(event.nativeEvent.layout.width); }, [thumbWidth], ); const thumbAnimatedStyle = useAnimatedStyle(() => ({ transform: [ { translateX: progress.get() * travel.get(), }, ], })); return { accessibilityState: { disabled, selected: resolvedCompleted, }, complete, completed: resolvedCompleted, gesture, onThumbLayout, onTrackLayout, progress, reset, thumbAnimatedStyle, thumbWidth, trackWidth, travel, };}SlideButton
SlideButton.Label
Text that automatically inherits the current layer and tone colors.
SlideButton.Label extends all props from TextProps, with the additional component-specific props shown below.
SlideButton.OverlayContent
Confirmation layer progressively revealed as the thumb advances.
SlideButton.OverlayContent extends all props from ViewProps, with the additional component-specific props shown below.
SlideButton.Thumb
Measured draggable handle with consumer-owned content.
SlideButton.Thumb extends all props from ViewProps, with the additional component-specific props shown below.
SlideButton.UnderlayContent
Static instruction visible before and beside progress.
SlideButton.UnderlayContent extends all props from ViewProps, with the additional component-specific props shown below.
