gestures
Pressure Save
A long-press CTA that visualizes commitment, cancels safely, and confirms without a spinner.
iOSAndroidExpo Go
hapticsgesture handlerreanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add expo-haptics react-native-gesture-handler react-native-reanimatedInstall tailwind variants
pnpm add tailwind-variantsUsage
PressureSaveExample.tsximport { 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> );}pressure-save/config.tsexport const PRESSURE_SAVE_CONFIG = { cancelDuration: 220, duration: 900, releaseDuration: 320,} as const;pressure-save/types.tsimport 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.tsximport { 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.tsimport { 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.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 { 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, };}