gestures
Gesture Tutor
A non-blocking overlay that demonstrates gestures over real interactive content.
iOSAndroidExpo Go
reanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimatedBehavior
- The wrapped content determines the component size.
- The overlay uses
pointerEvents="none"and does not block the real gesture. - Changing
activefromfalsetotruereplays the guide. - Reduce Motion replaces travel with a stationary cue while preserving the completion timing.
Install tailwind variants
pnpm add tailwind-variantsUsage
GestureTutorExample.tsximport { Text, View } from "react-native";import { GestureTutor } from "@animations/ui/components/gestures/gesture-tutor/uniwind";export function GestureTutorExample() { return ( <GestureTutor active className="w-full" classNames={{ indicator: "bg-violet-600", trail: "bg-violet-600", }} gesture="drag" onComplete={() => console.log("Guide completed")} > <View className="p-6"> <Text className="text-lg font-extrabold">Drag this card</Text> </View> </GestureTutor> );}gesture-tutor/config.tsexport const GESTURE_TUTOR_CONFIG = { distance: 56, duration: 1050, repeatCount: 2, resetDuration: 420, startDelay: 300,} as const;gesture-tutor/types.tsimport type { ReactNode } from "react";export type GestureTutorGesture = | "drag" | "long-press" | "pinch" | "swipe-down" | "swipe-left" | "swipe-right" | "swipe-up";export type GestureTutorBaseProps = { active?: boolean; children: ReactNode; distance?: number; duration?: number; gesture: GestureTutorGesture; loop?: boolean; onComplete?: () => void; repeatCount?: number; startDelay?: number;};gesture-tutor/uniwind/index.tsximport { View } from "react-native";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import type { GestureTutorBaseProps } from "../types";import { useGestureTutor } from "../use-gesture-tutor";import { gestureTutorVariants, type GestureTutorSlot,} from "./variants";export type GestureTutorProps = GestureTutorBaseProps & { className?: string; classNames?: SlotsToClasses<GestureTutorSlot>;};export function GestureTutor({ active, children, className, classNames, distance, duration, gesture, loop, onComplete, repeatCount, startDelay,}: GestureTutorProps) { const { indicatorAnimatedStyle, pulseAnimatedStyle, secondaryIndicatorAnimatedStyle, trailAnimatedStyle, } = useGestureTutor({ active, distance, duration, gesture, loop, onComplete, repeatCount, startDelay, }); const { container, indicator, indicatorCore, overlay, pulse, trail, } = gestureTutorVariants(); return ( <View className={container({ className: [className, classNames?.container], })} > {children} <View className={overlay({ className: classNames?.overlay, })} pointerEvents="none" > <Animated.View className={trail({ className: classNames?.trail })} style={trailAnimatedStyle} /> <Animated.View className={pulse({ className: classNames?.pulse })} style={pulseAnimatedStyle} /> <Animated.View className={indicator({ className: classNames?.indicator, })} style={indicatorAnimatedStyle} > <View className={indicatorCore({ className: classNames?.indicatorCore, })} /> </Animated.View> {gesture === "pinch" && ( <Animated.View className={indicator({ className: classNames?.indicator, })} style={secondaryIndicatorAnimatedStyle} > <View className={indicatorCore({ className: classNames?.indicatorCore, })} /> </Animated.View> )} </View> </View> );}gesture-tutor/uniwind/variants.tsimport { tv } from "tailwind-variants";export const gestureTutorSlots = { container: "relative", indicator: "absolute size-9 items-center justify-center rounded-full bg-[#171713]", indicatorCore: "size-2.5 rounded-full bg-white", overlay: "absolute inset-0 items-center justify-center", pulse: "absolute size-12 rounded-full border-2 border-[#171713]", trail: "absolute h-1 w-18 rounded-sm bg-[#171713]",} as const;export type GestureTutorSlot = keyof typeof gestureTutorSlots;export const gestureTutorVariants = tv({ slots: gestureTutorSlots,});gesture-tutor/use-gesture-tutor.tsimport { useCallback } from "react";import { cancelAnimation, Easing, interpolate, runOnJS, useAnimatedReaction, useAnimatedStyle, useReducedMotion, useSharedValue, withDelay, withRepeat, withSequence, withTiming,} from "react-native-reanimated";import { GESTURE_TUTOR_CONFIG } from "./config";import type { GestureTutorBaseProps } from "./types";type UseGestureTutorOptions = Pick< GestureTutorBaseProps, | "active" | "distance" | "duration" | "gesture" | "loop" | "onComplete" | "repeatCount" | "startDelay">;export function useGestureTutor({ active = true, distance = GESTURE_TUTOR_CONFIG.distance, duration = GESTURE_TUTOR_CONFIG.duration, gesture, loop = false, onComplete, repeatCount = GESTURE_TUTOR_CONFIG.repeatCount, startDelay = GESTURE_TUTOR_CONFIG.startDelay,}: UseGestureTutorOptions) { const progress = useSharedValue(0); const reducedMotion = useReducedMotion(); const complete = useCallback(() => { onComplete?.(); }, [onComplete]); useAnimatedReaction( () => active, (isActive, wasActive) => { if (isActive === wasActive) { return; } cancelAnimation(progress); progress.set(0); if (!isActive) { return; } if (reducedMotion) { progress.set( withSequence( withTiming(0.5, { duration: 0 }), withDelay( duration, withTiming(0, { duration: 0 }, (finished) => { if (finished) { runOnJS(complete)(); } }), ), ), ); return; } progress.set( withRepeat( withSequence( withDelay( startDelay, withTiming(1, { duration, easing: Easing.inOut(Easing.cubic), }), ), withTiming(0, { duration: GESTURE_TUTOR_CONFIG.resetDuration, easing: Easing.out(Easing.quad), }), ), loop ? -1 : Math.max(1, repeatCount), false, (finished) => { if (finished) { runOnJS(complete)(); } }, ), ); }, [ active, complete, duration, loop, progress, reducedMotion, repeatCount, startDelay, ], ); const indicatorAnimatedStyle = useAnimatedStyle(() => { const value = progress.get(); let translateX = 0; let translateY = 0; if (gesture === "drag") { translateX = interpolate(value, [0, 1], [-distance / 2, distance / 2]); translateY = interpolate(value, [0, 1], [distance / 3, -distance / 3]); } if (gesture === "swipe-down") { translateY = interpolate(value, [0, 1], [-distance / 2, distance / 2]); } if (gesture === "swipe-left") { translateX = interpolate(value, [0, 1], [distance / 2, -distance / 2]); } if (gesture === "swipe-right") { translateX = interpolate(value, [0, 1], [-distance / 2, distance / 2]); } if (gesture === "swipe-up") { translateY = interpolate(value, [0, 1], [distance / 2, -distance / 2]); } if (gesture === "pinch") { translateX = interpolate(value, [0, 1], [-distance / 4, -distance / 2]); } return { opacity: active ? interpolate(value, [0, 0.1, 0.88, 1], [0, 1, 1, 0]) : 0, transform: [ { translateX }, { translateY }, { scale: gesture === "long-press" ? interpolate(value, [0, 0.55, 1], [0.86, 1.18, 1]) : 1, }, ], }; }); const secondaryIndicatorAnimatedStyle = useAnimatedStyle(() => ({ opacity: gesture === "pinch" && active ? interpolate( progress.get(), [0, 0.1, 0.88, 1], [0, 1, 1, 0], ) : 0, transform: [ { translateX: interpolate( progress.get(), [0, 1], [distance / 4, distance / 2], ), }, ], })); const trailAnimatedStyle = useAnimatedStyle(() => ({ opacity: gesture === "long-press" || gesture === "pinch" || !active ? 0 : interpolate(progress.get(), [0, 0.15, 0.8, 1], [0, 0.35, 0.2, 0]), transform: [ { rotate: gesture === "swipe-down" || gesture === "swipe-up" ? "90deg" : "0deg", }, { scaleX: gesture === "swipe-left" || gesture === "swipe-right" || gesture === "drag" ? interpolate(progress.get(), [0, 1], [0.25, 1]) : 0.2, }, { scaleY: gesture === "swipe-down" || gesture === "swipe-up" ? interpolate(progress.get(), [0, 1], [0.25, 1]) : 0.2, }, ], })); const pulseAnimatedStyle = useAnimatedStyle(() => ({ opacity: gesture === "long-press" && active ? interpolate(progress.get(), [0, 0.55, 1], [0, 0.35, 0]) : 0, transform: [ { scale: interpolate(progress.get(), [0, 1], [0.6, 1.8]), }, ], })); return { indicatorAnimatedStyle, pulseAnimatedStyle, secondaryIndicatorAnimatedStyle, trailAnimatedStyle, };}