Veltrix

gestures

Slide Button

A measured swipe-to-confirm control with threshold projection, controlled completion, and optional automatic reset.

AppleiOSAndroidAndroidExpoExpo Go
hapticsgesture handlerreanimated
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add expo-haptics react-native-gesture-handler react-native-reanimated

Anatomy

1 file

anatomy.tsx
<SlideButton>  <SlideButton.Label />  <SlideButton.OverlayContent />  <SlideButton.Thumb />  <SlideButton.UnderlayContent /></SlideButton>

Controlled completion

ControlledCompletionExample.tsx
import { 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-variants

Usage

SlideButtonExample.tsx
import { 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>  );}

Component files

5 files

slide-button/config.ts
export const SLIDE_BUTTON_CONFIG = {  autoResetDelay: 1400,  completionThreshold: 0.82,  inset: 5,  spring: {    damping: 24,    mass: 0.8,    stiffness: 260,  },} as const;
slide-button/types.ts
import 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.tsx
import {  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.ts
import { 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.ts
import {  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,  };}

API reference

Generated directly from the exported component props.

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.