Veltrix

gestures

Gesture Tutor

A non-blocking overlay that demonstrates gestures over real interactive content.

AppleiOSAndroidAndroidExpoExpo Go
reanimated
GitHubOpen in GitHub

Installation

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

pnpm add react-native-reanimated

Behavior

  • The wrapped content determines the component size.
  • The overlay uses pointerEvents="none" and does not block the real gesture.
  • Changing active from false to true replays the guide.
  • Reduce Motion replaces travel with a stationary cue while preserving the completion timing.

Install tailwind variants

pnpm add tailwind-variants

Usage

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

Component files

5 files

gesture-tutor/config.ts
export const GESTURE_TUTOR_CONFIG = {  distance: 56,  duration: 1050,  repeatCount: 2,  resetDuration: 420,  startDelay: 300,} as const;
gesture-tutor/types.ts
import 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.tsx
import { 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.ts
import { 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.ts
import { 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,  };}

API reference

Generated directly from the exported component props.

GestureTutor