Veltrix

gestures

Pressure Save

A long-press CTA that visualizes commitment, cancels safely, and confirms without a spinner.

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

Install tailwind variants

pnpm add tailwind-variants

Usage

PressureSaveExample.tsx
import { Text } from "react-native";import { PressureSave } from "@animations/ui/components/gestures/pressure-save/uniwind";export function PressureSaveExample() {  return (    <PressureSave      className="w-full"      classNames={{        container: "bg-black",        fill: "bg-violet-600",      }}      direction="right"      onSave={() => console.log("Draft saved")}    >      {({ saved }) => (        <Text className="font-black text-white">          {saved ? "Draft saved" : "Hold to save"}        </Text>      )}    </PressureSave>  );}

Component files

5 files

pressure-save/config.ts
export const PRESSURE_SAVE_CONFIG = {  cancelDuration: 220,  duration: 900,  releaseDuration: 320,} as const;
pressure-save/types.ts
import type { ReactNode } from "react";export type PressureSaveDirection = "down" | "left" | "right" | "up";export type PressureSaveRenderState = {  saved: boolean;};export type PressureSaveBaseProps = {  accessibilityLabel?: string;  children:    | ReactNode    | ((state: PressureSaveRenderState) => ReactNode);  direction?: PressureSaveDirection;  disabled?: boolean;  duration?: number;  haptics?: boolean;  onSave?: () => void;  resetOnComplete?: boolean;};
pressure-save/uniwind/index.tsx
import { GestureDetector } from "react-native-gesture-handler";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import type { PressureSaveBaseProps } from "../types";import { usePressureSave } from "../use-pressure-save";import {  pressureSaveVariants,  type PressureSaveSlot,} from "./variants";export type PressureSaveProps = PressureSaveBaseProps & {  className?: string;  classNames?: SlotsToClasses<PressureSaveSlot>;};export function PressureSave({  accessibilityLabel,  children,  className,  classNames,  direction = "up",  disabled,  duration,  haptics,  onSave,  resetOnComplete,}: PressureSaveProps) {  const {    accessibilityState,    buttonAnimatedStyle,    fillAnimatedStyle,    gesture,    onLayout,    save,    saved,  } = usePressureSave({    direction,    disabled,    duration,    haptics,    onSave,    resetOnComplete,  });  const {    container,    content,    fill,  } = pressureSaveVariants({ direction, disabled });  return (    <GestureDetector gesture={gesture}>      <Animated.View        accessibilityLabel={accessibilityLabel ?? "Save"}        accessibilityRole="button"        accessibilityState={accessibilityState}        accessible        className={container({          className: [className, classNames?.container],        })}        onAccessibilityTap={save}        onLayout={onLayout}        style={buttonAnimatedStyle}      >        <Animated.View          className={fill({ className: classNames?.fill })}          pointerEvents="none"          style={fillAnimatedStyle}        />        <Animated.View          className={content({            className: classNames?.content,          })}        >          {typeof children === "function"            ? children({ saved })            : children}        </Animated.View>      </Animated.View>    </GestureDetector>  );}
pressure-save/uniwind/variants.ts
import { tv } from "tailwind-variants";export const pressureSaveSlots = {  container:    "overflow-hidden rounded-[22px] bg-[#171713] px-6 py-5",  content: "items-center justify-center",  fill: "absolute bg-[#ff6b45]",} as const;export type PressureSaveSlot = keyof typeof pressureSaveSlots;export const pressureSaveVariants = tv({  slots: pressureSaveSlots,  variants: {    direction: {      down: {        fill: "left-0 top-0",      },      left: {        fill: "bottom-0 right-0",      },      right: {        fill: "bottom-0 left-0",      },      up: {        fill: "bottom-0 left-0",      },    },    disabled: {      true: {        container: "opacity-[0.45]",      },    },  },  defaultVariants: {    direction: "up",  },});
pressure-save/use-pressure-save.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 {  Easing,  interpolate,  runOnJS,  useAnimatedStyle,  useReducedMotion,  useSharedValue,  withTiming,} from "react-native-reanimated";import { PRESSURE_SAVE_CONFIG } from "./config";import type { PressureSaveBaseProps } from "./types";type UsePressureSaveOptions = Pick<  PressureSaveBaseProps,  | "disabled"  | "direction"  | "duration"  | "haptics"  | "onSave"  | "resetOnComplete">;export function usePressureSave({  direction = "up",  disabled = false,  duration = PRESSURE_SAVE_CONFIG.duration,  haptics = true,  onSave,  resetOnComplete = false,}: UsePressureSaveOptions) {  const [saved, setSaved] = useState(false);  const committed = useSharedValue(false);  const containerHeight = useSharedValue(0);  const containerWidth = useSharedValue(0);  const pressure = useSharedValue(0);  const reducedMotion = useReducedMotion();  const reset = useCallback(() => {    setSaved(false);  }, []);  const save = useCallback(() => {    if (disabled) {      return;    }    setSaved(true);    if (haptics) {      void Haptics.notificationAsync(        Haptics.NotificationFeedbackType.Success,      );    }    onSave?.();  }, [disabled, haptics, onSave]);  const gesture = useMemo(    () =>      Gesture.LongPress()        .enabled(!disabled)        .minDuration(duration)        .maxDistance(18)        .onBegin(() => {          committed.set(false);          pressure.set(0);          pressure.set(            reducedMotion              ? 1              : withTiming(1, {                  duration,                  easing: Easing.inOut(Easing.quad),                }),          );          runOnJS(reset)();        })        .onStart(() => {          committed.set(true);          pressure.set(1);          runOnJS(save)();        })        .onFinalize(() => {          const shouldReset = committed.get() && resetOnComplete;          if (reducedMotion) {            pressure.set(0);            if (shouldReset) {              runOnJS(reset)();            }            return;          }          pressure.set(            withTiming(              0,              {                duration: committed.get()                  ? PRESSURE_SAVE_CONFIG.releaseDuration                  : PRESSURE_SAVE_CONFIG.cancelDuration,                easing: Easing.out(Easing.cubic),              },              (finished) => {                if (finished && shouldReset) {                  runOnJS(reset)();                }              },            ),          );        }),    [      committed,      disabled,      duration,      pressure,      reducedMotion,      reset,      resetOnComplete,      save,    ],  );  const onLayout = useCallback(    (event: LayoutChangeEvent) => {      containerHeight.set(event.nativeEvent.layout.height);      containerWidth.set(event.nativeEvent.layout.width);    },    [containerHeight, containerWidth],  );  const buttonAnimatedStyle = useAnimatedStyle(() => ({    transform: [      {        scale: interpolate(          pressure.get(),          [0, 1],          [1, 0.97],        ),      },      {        translateY: interpolate(          pressure.get(),          [0, 1],          [0, 2],        ),      },    ],  }));  const fillAnimatedStyle = useAnimatedStyle(() => ({    height:      direction === "down" || direction === "up"        ? pressure.get() * containerHeight.get()        : containerHeight.get(),    width:      direction === "left" || direction === "right"        ? pressure.get() * containerWidth.get()        : containerWidth.get(),  }));  return {    accessibilityState: { disabled },    buttonAnimatedStyle,    fillAnimatedStyle,    gesture,    onLayout,    save,    saved,  };}

API reference

Generated directly from the exported component props.

PressureSave