Claude Skill

mobile-animation-gesture-handler

React Native Gesture Handler - gesture types, GestureDetector, gesture composition, state machine, platform-specific gestures, swipeable rows, hover gestures

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_mobile-animation-gesture-handler_skills_mobile-animation-gesture-handler-3a51ef5.zip · 18 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/mobile-animation-gesture-handler/skills/mobile-animation-gesture-handler
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git git clone https://github.com/agents-inc/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

React Native Gesture Handler Patterns

Quick Guide: Use Gesture Handler's v2 builder API (Gesture.Pan(), Gesture.Tap(), etc.) with GestureDetector for all touch interactions. Wrap your app root in GestureHandlerRootView. Compose gestures with Gesture.Simultaneous(), Gesture.Race(), and Gesture.Exclusive(). Use onChange (not onUpdate) when working with animation shared values -- onChange provides deltas (changeX), onUpdate provides cumulative values (translationX). Gesture callbacks are automatically workletized when your animation library is installed.


<critical_requirements>

CRITICAL: Before Using This Skill

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST wrap the app root in GestureHandlerRootView -- gestures will silently fail without it)

(You MUST use GestureDetector with the builder API (Gesture.Pan(), etc.) -- NOT the legacy PanGestureHandler components)

(You MUST use onChange for incremental shared value updates and onUpdate for cumulative values -- mixing them causes drift)

(You MUST use Gesture.Simultaneous() for multi-touch interactions (pinch + pan) -- without it, only one gesture activates)

(You MUST NOT nest GestureDetector components using different API styles (hooks vs builder) under the same root -- this causes undefined behavior)

</critical_requirements>


Auto-detection: react-native-gesture-handler, GestureDetector, GestureHandlerRootView, Gesture.Pan, Gesture.Tap, Gesture.Pinch, Gesture.Rotation, Gesture.LongPress, Gesture.Fling, Gesture.Hover, Gesture.Simultaneous, Gesture.Race, Gesture.Exclusive, Swipeable, ReanimatedSwipeable, onBegin, onStart, onChange, onUpdate, onEnd, onFinalize

When to use:

  • Adding pan, pinch, tap, rotation, long-press, fling, or hover gestures to React Native views
  • Composing multiple gestures on the same view (pinch-to-zoom + drag)
  • Building swipeable list rows with reveal actions
  • Replacing React Native's built-in Gesture Responder System (PanResponder)
  • Implementing gesture-driven animations with shared values
  • Adding hover interactions for iPad trackpad, desktop, or web targets

Key patterns covered:

  • GestureDetector + builder API for all gesture types
  • Gesture composition: Simultaneous, Race, Exclusive
  • Gesture state machine and lifecycle callbacks
  • Shared value integration in gesture callbacks (onChange vs onUpdate)
  • ReanimatedSwipeable for swipeable list rows
  • Hover gesture for pointer devices (iPad trackpad, mouse, stylus)
  • Platform-specific gesture configuration (Android ripple, iOS haptics)
  • Cross-component gesture relations (requireToFail, simultaneousWith, block)

When NOT to use:

  • Simple button taps (use Pressable or TouchableOpacity from React Native core)
  • Scroll-only interactions (use ScrollView or FlatList directly)
  • Web-only applications without React Native

Detailed Resources:




<decision_framework>

Decision Framework

Which Gesture Type?

What user interaction are you handling?
├─ Drag/move element         → Gesture.Pan()
├─ Single tap                → Gesture.Tap()
├─ Double tap                → Gesture.Tap().numberOfTaps(2)
├─ Long press                → Gesture.LongPress()
├─ Pinch to zoom             → Gesture.Pinch()
├─ Two-finger rotate         → Gesture.Rotation()
├─ Quick directional swipe   → Gesture.Fling()
├─ Mouse/trackpad hover      → Gesture.Hover()
└─ Wrap a native gesture     → Gesture.Native() (for ScrollView, etc.)

Which Composition?

How should multiple gestures interact?
├─ All active at once (pan + pinch + rotate in photo viewer)
│   └─ Gesture.Simultaneous(gesture1, gesture2, ...)
├─ First to activate wins (swipe vs long-press on same view)
│   └─ Gesture.Race(gesture1, gesture2, ...)
├─ Priority order (double-tap must beat single-tap)
│   └─ Gesture.Exclusive(highPriority, lowPriority)
└─ Gestures on DIFFERENT components (child drag vs parent scroll)
    ├─ Parent waits for child to fail → parent.requireExternalGestureToFail(child)
    ├─ Both active simultaneously     → child.simultaneousWith(parent)
    └─ Child blocks parent            → child.blocksExternalGesture(parent)

onChange vs onUpdate?

How are you using the gesture position data?
├─ Adding to an offset (shared value += delta)
│   └─ Use onChange (provides changeX, changeY)
├─ Setting absolute position from gesture start
│   └─ Use onUpdate (provides translationX, translationY)
└─ Need both (rare)
    └─ Use onChange for offsets + onUpdate for display values

</decision_framework>


<red_flags>

RED FLAGS

High Priority Issues:

  • Missing GestureHandlerRootView at app root -- gestures silently fail with no error message
  • Using legacy PanGestureHandler/TapGestureHandler components -- deprecated, use Gesture.Pan() + GestureDetector
  • Using onUpdate to add deltas to shared values -- onUpdate provides cumulative values, not deltas; use onChange for incremental updates
  • Nesting multiple GestureDetector components for gestures that should compose -- use Gesture.Simultaneous()/Race()/Exclusive() instead
  • Using React.PanResponder -- replaced entirely by Gesture Handler; PanResponder runs on JS thread and blocks the UI

Medium Priority Issues:

  • Not specifying minDistance on Gesture.Pan() when coexisting with tap gestures -- pan activates immediately at 0px, stealing taps
  • Missing onFinalize cleanup -- onEnd only fires on success; cancelled/failed gestures skip it
  • Using Swipeable instead of ReanimatedSwipeable -- legacy component runs animations on JS thread
  • Creating new gesture instances inside render (not in useMemo or outside component) -- causes gesture to reset every render

Gotchas & Edge Cases:

  • Gesture.Exclusive(doubleTap, singleTap) -- double-tap MUST be the first argument (higher priority) or it will never fire because single-tap activates first
  • Android modals need their own GestureHandlerRootView -- gestures in modals fail silently without it
  • onChange callback is NOT available in the v3 hooks API -- it was removed; use onUpdate with the change* properties on the event object instead
  • Gesture callbacks are automatically workletized when your animation library is installed -- don't manually add "worklet" directives unless you need them without the animation library
  • Gesture.Native() wraps platform-native gestures (ScrollView, FlatList) -- use it when you need to create relations between custom gestures and native scrolling
  • onFinalize receives (event, success) -- the success boolean tells you whether the gesture ended normally (END) or was cancelled/failed
  • Reusing the same gesture instance across multiple GestureDetector components causes undefined behavior -- create separate instances
  • iPad trackpad two-finger gestures require enableTrackpadTwoFingerGesture on both Pan and ReanimatedSwipeable
  • hoverEffect prop on GestureDetector only works on iOS 17.0+ and provides system-level visual effects
  • Gesture.Fling() only fires at the end of the fling, not continuously -- use Gesture.Pan() if you need continuous position tracking during a swipe

</red_flags>


<critical_reminders>

CRITICAL REMINDERS

All code must follow project conventions in CLAUDE.md

(You MUST wrap the app root in GestureHandlerRootView -- gestures will silently fail without it)

(You MUST use GestureDetector with the builder API (Gesture.Pan(), etc.) -- NOT the legacy PanGestureHandler components)

(You MUST use onChange for incremental shared value updates and onUpdate for cumulative values -- mixing them causes drift)

(You MUST use Gesture.Simultaneous() for multi-touch interactions (pinch + pan) -- without it, only one gesture activates)

(You MUST NOT nest GestureDetector components using different API styles (hooks vs builder) under the same root -- this causes undefined behavior)

Failure to follow these rules will cause silent gesture failures, animation drift, and broken multi-touch interactions.

</critical_reminders>

Files (skills)
  • examples
    • advanced.md 6.7 KB
      # Gesture Handler - Advanced Patterns
      
      > Hover gestures, manual control, platform config. See [core.md](core.md) for fundamentals, [composition.md](composition.md) for multi-gesture.
      
      **Related:** [SKILL.md](../SKILL.md) for decision framework, [reference.md](../reference.md) for gesture type table.
      
      ---
      
      ## Pattern 1: Hover Gesture (Pointer Devices)
      
      Hover gestures detect mouse, stylus, or trackpad hovering. They fire on iPad (with trackpad/mouse), macOS, and web -- but NOT on phone touch.
      
      ```typescript
      import { Gesture, GestureDetector } from "react-native-gesture-handler";
      import Animated, {
        useSharedValue,
        useAnimatedStyle,
        withTiming,
      } from "react-native-reanimated";
      
      const HOVER_SCALE = 1.05;
      const TIMING_CONFIG = { duration: 150 };
      
      function HoverableCard({ children }: { children: React.ReactNode }) {
        const isHovered = useSharedValue(false);
      
        const hover = Gesture.Hover()
          .onBegin(() => {
            isHovered.value = true;
          })
          .onEnd(() => {
            isHovered.value = false;
          });
      
        const animatedStyle = useAnimatedStyle(() => ({
          transform: [
            {
              scale: withTiming(
                isHovered.value ? HOVER_SCALE : 1,
                TIMING_CONFIG,
              ),
            },
          ],
          shadowOpacity: withTiming(isHovered.value ? 0.2 : 0.1, TIMING_CONFIG),
        }));
      
        return (
          <GestureDetector gesture={hover}>
            <Animated.View style={[styles.card, animatedStyle]}>
              {children}
            </Animated.View>
          </GestureDetector>
        );
      }
      ```
      
      **Why good:** `onBegin`/`onEnd` for hover enter/exit, `withTiming` for smooth transitions, works on iPad trackpad and web
      
      ### iOS System Hover Effects
      
      On iOS 17.0+, `GestureDetector` supports a `hoverEffect` prop for system-provided visual effects. These are lightweight alternatives to custom hover animations.
      
      ```typescript
      <GestureDetector gesture={tap} hoverEffect="lift">
        <Animated.View style={styles.button}>
          <Text>Hover me</Text>
        </Animated.View>
      </GestureDetector>
      ```
      
      Available effects: `"highlight"` (dim overlay), `"lift"` (raise with shadow), `"automatic"` (system chooses).
      
      **Platform note:** `hoverEffect` only works on iOS 17.0+ with pointer devices. It is ignored on Android and earlier iOS versions.
      
      ---
      
      ## Pattern 2: Manual Gesture (Custom Recognition Logic)
      
      Use `Gesture.Manual()` when none of the built-in gestures match your recognition criteria. You control state transitions manually via the `GestureStateManager`.
      
      ```typescript
      import {
        Gesture,
        GestureDetector,
        GestureStateManager,
      } from "react-native-gesture-handler";
      
      const MIN_MOVEMENT = 30;
      const MAX_TIME_MS = 500;
      
      function CustomGestureView() {
        const gesture = Gesture.Manual()
          .onTouchesDown((event, stateManager) => {
            // Only recognize single-finger touches
            if (event.numberOfTouches === 1) {
              stateManager.begin();
            } else {
              stateManager.fail();
            }
          })
          .onTouchesMove((event, stateManager) => {
            const touch = event.allTouches[0];
            if (touch && Math.abs(touch.absoluteX - touch.x) > MIN_MOVEMENT) {
              stateManager.activate();
            }
          })
          .onTouchesUp((_event, stateManager) => {
            stateManager.end();
          });
      
        return (
          <GestureDetector gesture={gesture}>
            <View style={styles.container} />
          </GestureDetector>
        );
      }
      ```
      
      **Why good:** full control over state transitions (begin, activate, fail, end), useful for gestures that don't fit any standard type
      
      **When to use:** complex multi-step gesture recognition, gestures combining time + distance thresholds, or custom shapes (circle gesture, Z-pattern, etc.).
      
      ---
      
      ## Pattern 3: Platform-Specific Gesture Config
      
      ```typescript
      import { Platform } from "react-native";
      
      const PAN_MIN_DISTANCE_IOS = 10;
      const PAN_MIN_DISTANCE_ANDROID = 15; // Android touch slop is slightly larger
      
      const pan = Gesture.Pan()
        .minDistance(
          Platform.select({
            ios: PAN_MIN_DISTANCE_IOS,
            android: PAN_MIN_DISTANCE_ANDROID,
            default: PAN_MIN_DISTANCE_IOS,
          }),
        )
        .onChange((event) => {
          translateX.value += event.changeX;
        });
      ```
      
      **Why good:** Android has a larger default touch slop than iOS; matching platform defaults prevents gestures feeling too sensitive or too sluggish per platform
      
      ### iPad Trackpad Two-Finger Pan
      
      ```typescript
      const pan = Gesture.Pan()
        .enableTrackpadTwoFingerGesture(true)
        .onChange((event) => {
          translateX.value += event.changeX;
        });
      ```
      
      **Why good:** enables two-finger trackpad swiping on iPad, which is the standard gesture for horizontal navigation; without this flag, trackpad two-finger gestures are not recognized
      
      ---
      
      ## Pattern 4: Gesture with runOnJS Bridge
      
      When gesture callbacks need to trigger JS-thread operations (navigation, API calls, state updates in non-worklet stores), use `runOnJS`.
      
      ```typescript
      import { runOnJS } from "react-native-reanimated";
      
      function SwipeToDismiss({ onDismiss }: { onDismiss: () => void }) {
        const translateX = useSharedValue(0);
        const DISMISS_THRESHOLD = 150;
      
        const pan = Gesture.Pan()
          .onChange((event) => {
            translateX.value += event.changeX;
          })
          .onEnd(() => {
            if (Math.abs(translateX.value) > DISMISS_THRESHOLD) {
              // Bridge to JS thread for navigation/state
              runOnJS(onDismiss)();
            } else {
              translateX.value = withSpring(0);
            }
          });
      
        // ...
      }
      ```
      
      **Why good:** gesture callbacks run on UI thread (worklets) for performance; `runOnJS` safely bridges back to JS when needed for non-animation logic
      
      **Gotcha:** Do NOT call `runOnJS` inside `onChange`/`onUpdate` (fires every frame) -- only in `onEnd`/`onFinalize`/`onStart` to avoid flooding the JS thread.
      
      ---
      
      ## Pattern 5: Fling Gesture (Quick Directional Swipe)
      
      Unlike Pan, Fling fires once at the end of a quick swipe in a specific direction. Use it for page navigation or dismissal, not for continuous tracking.
      
      ```typescript
      import { Directions } from "react-native-gesture-handler";
      
      function FlingNavigator({ onSwipeLeft, onSwipeRight }: NavigatorProps) {
        const swipeLeft = Gesture.Fling()
          .direction(Directions.LEFT)
          .onStart(() => {
            runOnJS(onSwipeLeft)();
          });
      
        const swipeRight = Gesture.Fling()
          .direction(Directions.RIGHT)
          .onStart(() => {
            runOnJS(onSwipeRight)();
          });
      
        const flings = Gesture.Simultaneous(swipeLeft, swipeRight);
      
        return (
          <GestureDetector gesture={flings}>
            <View style={styles.page}>{/* Page content */}</View>
          </GestureDetector>
        );
      }
      ```
      
      **Why good:** Fling is optimized for quick swipes, fires once with direction info, `Simultaneous` allows both directions to be recognized
      
      **When to use Fling vs Pan:** Use `Fling` for discrete navigation actions (go to next page). Use `Pan` when you need continuous position tracking during the swipe (dragging a card).
      
    • composition.md 8 KB
      # Gesture Handler - Composition Patterns
      
      > Multi-gesture composition and cross-component relations. See [core.md](core.md) for individual gesture types.
      
      **Related:** [SKILL.md](../SKILL.md) for composition decision framework, [reference.md](../reference.md) for quick reference tables.
      
      ---
      
      ## Pattern 1: Simultaneous Pan + Pinch + Rotation (Photo Viewer)
      
      The classic image viewer: drag, zoom, and rotate all at once.
      
      ```typescript
      import { Gesture, GestureDetector } from "react-native-gesture-handler";
      import Animated, {
        useSharedValue,
        useAnimatedStyle,
        withTiming,
      } from "react-native-reanimated";
      
      function PhotoViewer({ source }: { source: ImageSourcePropType }) {
        // Pan state
        const translateX = useSharedValue(0);
        const translateY = useSharedValue(0);
      
        // Pinch state
        const scale = useSharedValue(1);
        const savedScale = useSharedValue(1);
      
        // Rotation state
        const rotation = useSharedValue(0);
        const savedRotation = useSharedValue(0);
      
        const pan = Gesture.Pan()
          .onChange((event) => {
            translateX.value += event.changeX;
            translateY.value += event.changeY;
          });
      
        const pinch = Gesture.Pinch()
          .onUpdate((event) => {
            scale.value = savedScale.value * event.scale;
          })
          .onEnd(() => {
            savedScale.value = scale.value;
          });
      
        const rotate = Gesture.Rotation()
          .onUpdate((event) => {
            rotation.value = savedRotation.value + event.rotation;
          })
          .onEnd(() => {
            savedRotation.value = rotation.value;
          });
      
        // All three gestures active at the same time
        const composed = Gesture.Simultaneous(pan, pinch, rotate);
      
        const animatedStyle = useAnimatedStyle(() => ({
          transform: [
            { translateX: translateX.value },
            { translateY: translateY.value },
            { scale: scale.value },
            { rotate: `${rotation.value}rad` },
          ],
        }));
      
        return (
          <GestureDetector gesture={composed}>
            <Animated.Image source={source} style={[styles.image, animatedStyle]} />
          </GestureDetector>
        );
      }
      ```
      
      **Why good:** single `GestureDetector` with composed gesture, each gesture manages its own shared values, `Simultaneous` allows all three to be ACTIVE at once
      
      ```typescript
      // BAD: Separate GestureDetectors for gestures that should interact
      <GestureDetector gesture={pan}>
        <GestureDetector gesture={pinch}>
          <GestureDetector gesture={rotate}>
            <Animated.Image source={source} style={animatedStyle} />
          </GestureDetector>
        </GestureDetector>
      </GestureDetector>
      ```
      
      **Why bad:** nested detectors without explicit composition causes gestures to compete by default -- only one activates, the rest fail
      
      ---
      
      ## Pattern 2: Exclusive Double-Tap vs Single-Tap
      
      Double-tap and single-tap on the same element. Double-tap must have higher priority.
      
      ```typescript
      const DOUBLE_TAP_MAX_DELAY = 250;
      
      function TapableCard({ onTap, onDoubleTap }: TapHandlers) {
        const doubleTap = Gesture.Tap()
          .numberOfTaps(2)
          .maxDelay(DOUBLE_TAP_MAX_DELAY)
          .onStart(() => {
            runOnJS(onDoubleTap)();
          });
      
        const singleTap = Gesture.Tap()
          .onStart(() => {
            runOnJS(onTap)();
          });
      
        // CRITICAL: doubleTap FIRST = higher priority
        const taps = Gesture.Exclusive(doubleTap, singleTap);
      
        return (
          <GestureDetector gesture={taps}>
            <View style={styles.card}>
              <Text>Tap or double-tap</Text>
            </View>
          </GestureDetector>
        );
      }
      ```
      
      **Why good:** `Exclusive` with doubleTap first means the system waits to see if a second tap comes; if it does, doubleTap activates; if not, singleTap activates after the delay
      
      ```typescript
      // BAD: Wrong priority order
      const taps = Gesture.Exclusive(singleTap, doubleTap);
      ```
      
      **Why bad:** singleTap has higher priority, fires immediately on first tap, doubleTap never gets recognized
      
      ---
      
      ## Pattern 3: Race -- Swipe vs Long Press
      
      When two gestures compete and only one should win.
      
      ```typescript
      const LONG_PRESS_DURATION = 400;
      const MIN_SWIPE_DISTANCE = 50;
      
      function MessageBubble({ onSwipeReply, onLongPressMenu }: MessageHandlers) {
        const translateX = useSharedValue(0);
      
        const swipe = Gesture.Pan()
          .activeOffsetX([-MIN_SWIPE_DISTANCE, MIN_SWIPE_DISTANCE])
          .onChange((event) => {
            translateX.value += event.changeX;
          })
          .onFinalize(() => {
            if (Math.abs(translateX.value) > MIN_SWIPE_DISTANCE) {
              runOnJS(onSwipeReply)();
            }
            translateX.value = withTiming(0);
          });
      
        const longPress = Gesture.LongPress()
          .minDuration(LONG_PRESS_DURATION)
          .onStart(() => {
            runOnJS(onLongPressMenu)();
          });
      
        // First gesture to activate wins -- the other fails
        const gesture = Gesture.Race(swipe, longPress);
      
        return (
          <GestureDetector gesture={gesture}>
            <Animated.View style={animatedStyle}>
              <Text>Message content</Text>
            </Animated.View>
          </GestureDetector>
        );
      }
      ```
      
      **Why good:** `Race` ensures only one interaction happens -- either the user swipes (reply) or long-presses (context menu), never both; `activeOffsetX` delays pan activation until meaningful horizontal movement
      
      ---
      
      ## Pattern 4: Cross-Component Relations (Drag Inside ScrollView)
      
      When a draggable child lives inside a scrollable parent, use cross-component relations.
      
      ```typescript
      function DraggableInScrollView() {
        const childPan = Gesture.Pan()
          .onChange((event) => {
            translateY.value += event.changeY;
          });
      
        // Wrap the native ScrollView gesture so we can reference it
        const scrollGesture = Gesture.Native();
      
        // Method 1: Scroll waits for drag to fail
        // (scroll only works when not touching the draggable child)
        scrollGesture.requireExternalGestureToFail(childPan);
      
        // Method 2: Both active simultaneously
        // (scroll AND drag work at the same time -- rare but useful for parallax)
        // childPan.simultaneousWith(scrollGesture);
      
        // Method 3: Child blocks scroll while dragging
        // (scroll stops when dragging starts)
        // childPan.blocksExternalGesture(scrollGesture);
      
        return (
          <GestureDetector gesture={scrollGesture}>
            <Animated.ScrollView>
              <View style={styles.content}>
                <GestureDetector gesture={childPan}>
                  <Animated.View style={[styles.draggable, animatedStyle]} />
                </GestureDetector>
              </View>
            </Animated.ScrollView>
          </GestureDetector>
        );
      }
      ```
      
      **Key differences from composition:**
      
      | Same component               | Different components                |
      | ---------------------------- | ----------------------------------- |
      | `Gesture.Simultaneous(a, b)` | `a.simultaneousWith(b)`             |
      | `Gesture.Race(a, b)`         | `a.requireExternalGestureToFail(b)` |
      | `Gesture.Exclusive(a, b)`    | `a.blocksExternalGesture(b)`        |
      
      **Why this distinction matters:** Composition methods create a single composed gesture for one `GestureDetector`. Cross-component relations connect gestures on separate `GestureDetector` components in the view hierarchy.
      
      ---
      
      ## Pattern 5: Pan with Axis Locking
      
      Restrict pan to horizontal or vertical based on initial movement direction.
      
      ```typescript
      const AXIS_LOCK_THRESHOLD = 15;
      
      function AxisLockedPan() {
        const translateX = useSharedValue(0);
        const translateY = useSharedValue(0);
      
        const horizontalPan = Gesture.Pan()
          .activeOffsetX([-AXIS_LOCK_THRESHOLD, AXIS_LOCK_THRESHOLD])
          .failOffsetY([-AXIS_LOCK_THRESHOLD, AXIS_LOCK_THRESHOLD])
          .onChange((event) => {
            translateX.value += event.changeX;
          });
      
        const verticalPan = Gesture.Pan()
          .activeOffsetY([-AXIS_LOCK_THRESHOLD, AXIS_LOCK_THRESHOLD])
          .failOffsetX([-AXIS_LOCK_THRESHOLD, AXIS_LOCK_THRESHOLD])
          .onChange((event) => {
            translateY.value += event.changeY;
          });
      
        // Only one direction wins
        const pan = Gesture.Race(horizontalPan, verticalPan);
      
        return (
          <GestureDetector gesture={pan}>
            <Animated.View style={animatedStyle} />
          </GestureDetector>
        );
      }
      ```
      
      **Why good:** `activeOffsetX` + `failOffsetY` means the horizontal pan activates when horizontal movement exceeds threshold BUT fails if vertical movement exceeds threshold first. The `Race` composition ensures only one direction wins.
      
    • core.md 8.2 KB
      # Gesture Handler - Core Patterns
      
      > Fundamental gesture setup and individual gesture types. See [SKILL.md](../SKILL.md) for decision guidance and red flags.
      
      **Related:** [composition.md](composition.md) for multi-gesture patterns, [swipeable.md](swipeable.md) for list rows, [advanced.md](advanced.md) for hover and manual control.
      
      ---
      
      ## Pattern 1: App Root Setup
      
      ```typescript
      import { GestureHandlerRootView } from "react-native-gesture-handler";
      
      // CRITICAL: Must be at the actual root of the app
      export function App() {
        return (
          <GestureHandlerRootView style={{ flex: 1 }}>
            {/* Your navigation, providers, etc. */}
            <AppContent />
          </GestureHandlerRootView>
        );
      }
      ```
      
      **Why good:** single root, flex: 1 covers full screen, all gestures and relations work within this root
      
      ```typescript
      // BAD: Missing GestureHandlerRootView
      export function App() {
        return <AppContent />; // Gestures silently fail everywhere
      }
      ```
      
      **Why bad:** gestures won't be recognized anywhere in the app, and no error is thrown
      
      ### Android Modal Gotcha
      
      ```typescript
      import { Modal } from "react-native";
      import { GestureHandlerRootView } from "react-native-gesture-handler";
      
      // Android modals create a separate native view hierarchy
      function MyModal({ visible, onClose }: ModalProps) {
        return (
          <Modal visible={visible} onRequestClose={onClose}>
            <GestureHandlerRootView style={{ flex: 1 }}>
              <ModalContent />
            </GestureHandlerRootView>
          </Modal>
        );
      }
      ```
      
      **Why good:** Modal on Android has its own view hierarchy; without a separate root, gestures inside modals fail silently
      
      ---
      
      ## Pattern 2: Pan Gesture (Drag)
      
      Two approaches depending on how you use position data:
      
      ### onChange for Offset Accumulation (Most Common)
      
      ```typescript
      import { Gesture, GestureDetector } from "react-native-gesture-handler";
      import Animated, {
        useSharedValue,
        useAnimatedStyle,
        withDecay,
      } from "react-native-reanimated";
      
      const MIN_PAN_DISTANCE = 10;
      
      function DraggableBox() {
        const translateX = useSharedValue(0);
        const translateY = useSharedValue(0);
      
        const pan = Gesture.Pan()
          .minDistance(MIN_PAN_DISTANCE)
          .onChange((event) => {
            // onChange provides DELTAS (changeX, changeY)
            translateX.value += event.changeX;
            translateY.value += event.changeY;
          })
          .onFinalize((event) => {
            // Decay animation using final velocity
            translateX.value = withDecay({ velocity: event.velocityX });
            translateY.value = withDecay({ velocity: event.velocityY });
          });
      
        const animatedStyle = useAnimatedStyle(() => ({
          transform: [
            { translateX: translateX.value },
            { translateY: translateY.value },
          ],
        }));
      
        return (
          <GestureDetector gesture={pan}>
            <Animated.View style={[styles.box, animatedStyle]} />
          </GestureDetector>
        );
      }
      ```
      
      **Why good:** `onChange` gives deltas, so `+=` accumulates correctly; `onFinalize` runs on any terminal state; `minDistance` prevents accidental activation
      
      ### onUpdate for Absolute Position
      
      ```typescript
      function DraggableFromOrigin() {
        const startX = useSharedValue(0);
        const translateX = useSharedValue(0);
      
        const pan = Gesture.Pan()
          .onStart(() => {
            // Save current position at gesture start
            startX.value = translateX.value;
          })
          .onUpdate((event) => {
            // onUpdate provides CUMULATIVE translation from gesture start
            translateX.value = startX.value + event.translationX;
          });
      
        // ... animatedStyle and render same as above
      }
      ```
      
      **Why good:** `onUpdate` gives cumulative translation, combined with saved start position for absolute positioning
      
      ```typescript
      // BAD: Mixing onChange deltas with onUpdate cumulative values
      const pan = Gesture.Pan().onUpdate((event) => {
        translateX.value += event.translationX; // WRONG: translationX is cumulative, not delta
      });
      ```
      
      **Why bad:** `translationX` grows each frame, so `+=` causes exponential drift instead of linear movement
      
      ---
      
      ## Pattern 3: Tap Gesture
      
      ```typescript
      const TAP_MAX_DURATION = 300;
      
      function TappableCard({ onTap }: { onTap: () => void }) {
        const scale = useSharedValue(1);
      
        const tap = Gesture.Tap()
          .maxDuration(TAP_MAX_DURATION)
          .onBegin(() => {
            // Visual feedback when touch starts (BEGAN state)
            scale.value = withTiming(0.95);
          })
          .onStart(() => {
            // Gesture recognized (ACTIVE state) -- fire action
            runOnJS(onTap)();
          })
          .onFinalize(() => {
            // Reset regardless of success/failure/cancel
            scale.value = withTiming(1);
          });
      
        const animatedStyle = useAnimatedStyle(() => ({
          transform: [{ scale: scale.value }],
        }));
      
        return (
          <GestureDetector gesture={tap}>
            <Animated.View style={[styles.card, animatedStyle]}>
              <Text>Tap me</Text>
            </Animated.View>
          </GestureDetector>
        );
      }
      ```
      
      **Why good:** `onBegin` for immediate visual feedback (before recognition), `onStart` for the action, `onFinalize` for guaranteed cleanup, `runOnJS` bridges worklet back to JS
      
      ### Double Tap
      
      ```typescript
      const DOUBLE_TAP_MAX_DELAY = 250;
      
      const doubleTap = Gesture.Tap()
        .numberOfTaps(2)
        .maxDelay(DOUBLE_TAP_MAX_DELAY)
        .onStart(() => {
          scale.value = scale.value === 1 ? 2 : 1;
        });
      ```
      
      **Gotcha:** When combining single and double tap on the same view, use `Gesture.Exclusive(doubleTap, singleTap)` -- double-tap must be the FIRST argument (higher priority). If single-tap goes first, it fires immediately and double-tap never gets a chance.
      
      ---
      
      ## Pattern 4: Pinch Gesture (Zoom)
      
      ```typescript
      function PinchableImage({ source }: { source: ImageSourcePropType }) {
        const scale = useSharedValue(1);
        const savedScale = useSharedValue(1);
      
        const pinch = Gesture.Pinch()
          .onUpdate((event) => {
            // event.scale is relative to gesture start (1.0 = no change)
            scale.value = savedScale.value * event.scale;
          })
          .onEnd(() => {
            savedScale.value = scale.value;
          });
      
        const animatedStyle = useAnimatedStyle(() => ({
          transform: [{ scale: scale.value }],
        }));
      
        return (
          <GestureDetector gesture={pinch}>
            <Animated.Image source={source} style={[styles.image, animatedStyle]} />
          </GestureDetector>
        );
      }
      ```
      
      **Why good:** `savedScale` preserves accumulated scale between gestures; `event.scale` is multiplicative (1.0 = unchanged, 2.0 = doubled)
      
      **Gotcha:** `event.scale` starts at 1.0 for each gesture, not from the previous scale. Always multiply by the saved value.
      
      ---
      
      ## Pattern 5: Rotation Gesture
      
      ```typescript
      function RotatableView() {
        const rotation = useSharedValue(0);
        const savedRotation = useSharedValue(0);
      
        const rotationGesture = Gesture.Rotation()
          .onUpdate((event) => {
            // event.rotation is in radians, relative to gesture start
            rotation.value = savedRotation.value + event.rotation;
          })
          .onEnd(() => {
            savedRotation.value = rotation.value;
          });
      
        const animatedStyle = useAnimatedStyle(() => ({
          transform: [{ rotate: `${rotation.value}rad` }],
        }));
      
        return (
          <GestureDetector gesture={rotationGesture}>
            <Animated.View style={[styles.dial, animatedStyle]} />
          </GestureDetector>
        );
      }
      ```
      
      **Why good:** same saved-value pattern as pinch; rotation in radians is additive (not multiplicative like scale)
      
      ---
      
      ## Pattern 6: Long Press Gesture
      
      ```typescript
      const LONG_PRESS_DURATION = 500;
      
      function LongPressableItem({ onLongPress }: { onLongPress: () => void }) {
        const isPressed = useSharedValue(false);
      
        const longPress = Gesture.LongPress()
          .minDuration(LONG_PRESS_DURATION)
          .onBegin(() => {
            isPressed.value = true; // Immediate visual feedback
          })
          .onStart(() => {
            // Fires after minDuration elapses with finger still down
            runOnJS(onLongPress)();
          })
          .onFinalize(() => {
            isPressed.value = false;
          });
      
        // ... animated style using isPressed.value for opacity/scale
      
        return (
          <GestureDetector gesture={longPress}>
            <Animated.View style={animatedStyle}>
              <Text>Hold me</Text>
            </Animated.View>
          </GestureDetector>
        );
      }
      ```
      
      **Key distinction:** `onBegin` fires when touch is detected (visual feedback). `onStart` fires only after `minDuration` milliseconds with finger still down (action trigger). If the user lifts their finger before `minDuration`, the gesture transitions to FAILED and `onStart` never fires.
      
    • swipeable.md 6.3 KB
      # Gesture Handler - Swipeable Patterns
      
      > ReanimatedSwipeable for list row interactions. See [core.md](core.md) for gesture fundamentals.
      
      **Related:** [SKILL.md](../SKILL.md) for red flags, [reference.md](../reference.md) for ReanimatedSwipeable props.
      
      ---
      
      ## Pattern 1: Swipeable List Row with Delete Action
      
      ```typescript
      import { useCallback, useRef } from "react";
      import { Text, View, StyleSheet, Pressable } from "react-native";
      import ReanimatedSwipeable, {
        type SwipeableMethods,
      } from "react-native-gesture-handler/ReanimatedSwipeable";
      import Animated, {
        useAnimatedStyle,
        type SharedValue,
      } from "react-native-reanimated";
      
      const SWIPE_THRESHOLD = 40;
      const OVERSHOOT_FRICTION = 8;
      const DELETE_ACTION_WIDTH = 80;
      
      function RightActions(
        progress: SharedValue<number>,
        translation: SharedValue<number>,
      ) {
        const animatedStyle = useAnimatedStyle(() => ({
          // Slide in from the right, anchored to the swipe translation
          transform: [{ translateX: translation.value + DELETE_ACTION_WIDTH }],
        }));
      
        return (
          <Animated.View style={[styles.rightAction, animatedStyle]}>
            <Text style={styles.actionText}>Delete</Text>
          </Animated.View>
        );
      }
      
      interface SwipeableRowProps {
        item: { id: string; title: string };
        onDelete: (id: string) => void;
      }
      
      function SwipeableRow({ item, onDelete }: SwipeableRowProps) {
        const swipeableRef = useRef<SwipeableMethods>(null);
      
        const handleSwipeOpen = useCallback(() => {
          onDelete(item.id);
          swipeableRef.current?.close();
        }, [item.id, onDelete]);
      
        return (
          <ReanimatedSwipeable
            ref={swipeableRef}
            friction={2}
            rightThreshold={SWIPE_THRESHOLD}
            overshootFriction={OVERSHOOT_FRICTION}
            renderRightActions={RightActions}
            onSwipeableOpen={handleSwipeOpen}
          >
            <View style={styles.row}>
              <Text style={styles.rowText}>{item.title}</Text>
            </View>
          </ReanimatedSwipeable>
        );
      }
      
      const styles = StyleSheet.create({
        row: {
          padding: 16,
          backgroundColor: "#FFFFFF",
          borderBottomWidth: 1,
          borderBottomColor: "#E5E5E5",
        },
        rowText: {
          fontSize: 16,
          color: "#1A1A1A",
        },
        rightAction: {
          width: DELETE_ACTION_WIDTH,
          backgroundColor: "#FF3B30",
          justifyContent: "center",
          alignItems: "center",
        },
        actionText: {
          color: "#FFFFFF",
          fontWeight: "600",
          fontSize: 14,
        },
      });
      ```
      
      **Why good:** `ReanimatedSwipeable` runs animations on native thread (not JS), `overshootFriction: 8` gives native-feeling resistance, ref for programmatic control, `onSwipeableOpen` fires when the user completes the swipe
      
      ---
      
      ## Pattern 2: Swipeable with Left + Right Actions
      
      ```typescript
      const ARCHIVE_ACTION_WIDTH = 80;
      const DELETE_ACTION_WIDTH = 80;
      
      function LeftActions(
        progress: SharedValue<number>,
        translation: SharedValue<number>,
      ) {
        const animatedStyle = useAnimatedStyle(() => ({
          transform: [{ translateX: translation.value - ARCHIVE_ACTION_WIDTH }],
        }));
      
        return (
          <Animated.View style={[styles.archiveAction, animatedStyle]}>
            <Text style={styles.actionText}>Archive</Text>
          </Animated.View>
        );
      }
      
      function RightActions(
        progress: SharedValue<number>,
        translation: SharedValue<number>,
      ) {
        const animatedStyle = useAnimatedStyle(() => ({
          transform: [{ translateX: translation.value + DELETE_ACTION_WIDTH }],
        }));
      
        return (
          <Animated.View style={[styles.deleteAction, animatedStyle]}>
            <Text style={styles.actionText}>Delete</Text>
          </Animated.View>
        );
      }
      
      function EmailRow({ email, onArchive, onDelete }: EmailRowProps) {
        const swipeableRef = useRef<SwipeableMethods>(null);
      
        const handleOpen = useCallback(
          (direction: "left" | "right") => {
            if (direction === "left") {
              onArchive(email.id);
            } else {
              onDelete(email.id);
            }
            swipeableRef.current?.close();
          },
          [email.id, onArchive, onDelete],
        );
      
        return (
          <ReanimatedSwipeable
            ref={swipeableRef}
            friction={2}
            leftThreshold={ARCHIVE_ACTION_WIDTH}
            rightThreshold={DELETE_ACTION_WIDTH}
            overshootFriction={OVERSHOOT_FRICTION}
            renderLeftActions={LeftActions}
            renderRightActions={RightActions}
            onSwipeableOpen={handleOpen}
          >
            <View style={styles.emailRow}>
              <Text style={styles.subject}>{email.subject}</Text>
              <Text style={styles.preview}>{email.preview}</Text>
            </View>
          </ReanimatedSwipeable>
        );
      }
      ```
      
      **Why good:** `onSwipeableOpen` receives the direction string, actions slide in from their respective sides using translation offset
      
      ---
      
      ## Pattern 3: Swipeable in FlatList (Close Others on Open)
      
      ```typescript
      import { useCallback, useRef } from "react";
      import { FlatList } from "react-native";
      import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
      
      function SwipeableList({ items, onDelete }: SwipeableListProps) {
        // Track which swipeable is currently open
        const openSwipeableRef = useRef<SwipeableMethods | null>(null);
      
        const handleSwipeableOpen = useCallback((ref: SwipeableMethods) => {
          // Close the previously open row before opening the new one
          if (openSwipeableRef.current && openSwipeableRef.current !== ref) {
            openSwipeableRef.current.close();
          }
          openSwipeableRef.current = ref;
        }, []);
      
        const renderItem = useCallback(
          ({ item }: { item: ListItem }) => (
            <SwipeableRow
              item={item}
              onDelete={onDelete}
              onSwipeableOpen={handleSwipeableOpen}
            />
          ),
          [onDelete, handleSwipeableOpen],
        );
      
        return (
          <FlatList
            data={items}
            renderItem={renderItem}
            keyExtractor={(item) => item.id}
          />
        );
      }
      
      // SwipeableRow passes its ref up when opened
      function SwipeableRow({
        item,
        onDelete,
        onSwipeableOpen,
      }: SwipeableRowWithCallbackProps) {
        const swipeableRef = useRef<SwipeableMethods>(null);
      
        const handleOpen = useCallback(() => {
          if (swipeableRef.current) {
            onSwipeableOpen(swipeableRef.current);
          }
        }, [onSwipeableOpen]);
      
        return (
          <ReanimatedSwipeable
            ref={swipeableRef}
            renderRightActions={RightActions}
            onSwipeableWillOpen={handleOpen}
          >
            <RowContent item={item} />
          </ReanimatedSwipeable>
        );
      }
      ```
      
      **Why good:** `onSwipeableWillOpen` fires before animation completes (faster UX), only one row open at a time prevents visual clutter, ref-based approach avoids state re-renders
      
  • reference.md 8.3 KB
    # Gesture Handler Quick Reference
    
    > Decision frameworks, state machine, and gesture type reference. See [SKILL.md](SKILL.md) for philosophy and red flags.
    
    ---
    
    ## Gesture State Machine
    
    ```
                        ┌───────────────── FAILED ──────────────────┐
                        │                                           │
    UNDETERMINED ──> BEGAN ──> ACTIVE ──> END ──> UNDETERMINED     │
                                    │                               │
                                    └──> CANCELLED ─────────────────┘
    ```
    
    ### State Descriptions
    
    | State        | Meaning                                                        |
    | ------------ | -------------------------------------------------------------- |
    | UNDETERMINED | Initial/reset state -- gesture is idle                         |
    | BEGAN        | Touch detected, gathering data (not yet recognized)            |
    | ACTIVE       | Gesture recognized and tracking (finger still down)            |
    | END          | Gesture completed normally (finger lifted)                     |
    | FAILED       | Touch didn't meet gesture criteria (e.g., exceeded maxDist)    |
    | CANCELLED    | System cancelled the gesture (another gesture won competition) |
    
    ### Common State Flows
    
    ```
    Success:     UNDETERMINED -> BEGAN -> ACTIVE -> END -> UNDETERMINED
    Rejection:   UNDETERMINED -> BEGAN -> FAILED -> UNDETERMINED
    Cancellation: UNDETERMINED -> BEGAN -> ACTIVE -> CANCELLED -> UNDETERMINED
    ```
    
    ---
    
    ## Lifecycle Callback Reference
    
    | Callback     | State Transition        | Event Data                      | Use Case                    |
    | ------------ | ----------------------- | ------------------------------- | --------------------------- |
    | `onBegin`    | -> BEGAN                | Touch position (x, y)           | Visual feedback (highlight) |
    | `onStart`    | BEGAN -> ACTIVE         | Initial gesture data            | Begin tracking              |
    | `onUpdate`   | While ACTIVE            | Cumulative: translationX, scale | Absolute positioning        |
    | `onChange`   | While ACTIVE            | Incremental: changeX, changeY   | Offset accumulation         |
    | `onEnd`      | ACTIVE -> END           | Velocity, final position        | Success-only cleanup        |
    | `onFinalize` | -> END/FAILED/CANCELLED | Event + success boolean         | Guaranteed cleanup          |
    
    **Key distinction:**
    
    - `onEnd` fires ONLY on successful completion (END state)
    - `onFinalize` fires on ANY terminal state (END, FAILED, CANCELLED) -- use for cleanup that must always happen
    
    ---
    
    ## Gesture Type Reference
    
    | Gesture               | Activation Criteria                | Key Config                                  | Event Data                             |
    | --------------------- | ---------------------------------- | ------------------------------------------- | -------------------------------------- |
    | `Gesture.Pan()`       | Finger moves beyond minDistance    | `minDistance`, `minPointers`, `maxPointers` | translationX/Y, velocityX/Y, changeX/Y |
    | `Gesture.Tap()`       | Quick touch within maxDuration     | `numberOfTaps`, `maxDuration`, `maxDelay`   | x, y, absoluteX, absoluteY             |
    | `Gesture.LongPress()` | Touch held beyond minDuration      | `minDuration`, `maxDist`                    | x, y, duration                         |
    | `Gesture.Pinch()`     | Two fingers move closer/apart      | --                                          | scale, velocity, focalX, focalY        |
    | `Gesture.Rotation()`  | Two fingers rotate                 | --                                          | rotation (radians), velocity           |
    | `Gesture.Fling()`     | Quick directional swipe            | `direction`, `numberOfPointers`             | x, y (fires only at end)               |
    | `Gesture.Hover()`     | Mouse/stylus enters view area      | --                                          | x, y, absoluteX, absoluteY             |
    | `Gesture.Native()`    | Wraps platform ScrollView/FlatList | --                                          | Platform-dependent                     |
    
    ---
    
    ## Composition Quick Reference
    
    | Method                   | Behavior                                     | When to Use                               |
    | ------------------------ | -------------------------------------------- | ----------------------------------------- |
    | `Gesture.Simultaneous()` | All gestures can be active at the same time  | Pan + pinch + rotate (photo viewer)       |
    | `Gesture.Race()`         | First to activate wins, rest fail            | Swipe vs long-press (competing actions)   |
    | `Gesture.Exclusive()`    | Priority by argument order (first = highest) | Double-tap vs single-tap (disambiguation) |
    
    ### Cross-Component Relations
    
    | Method                             | Direction     | Effect                                    |
    | ---------------------------------- | ------------- | ----------------------------------------- |
    | `.requireExternalGestureToFail(g)` | One-to-many   | This gesture waits for `g` to fail first  |
    | `.simultaneousWith(g)`             | Bidirectional | Both gestures can be active together      |
    | `.blocksExternalGesture(g)`        | Many-to-one   | This gesture prevents `g` from activating |
    
    ---
    
    ## Pan Configuration Cheat Sheet
    
    ```typescript
    Gesture.Pan()
      .minDistance(10) // Min px before ACTIVE (default: 10 on iOS, varies Android)
      .minPointers(1) // Min simultaneous fingers
      .maxPointers(1) // Max simultaneous fingers (2 for two-finger pan)
      .activeOffsetX([-20, 20]) // Horizontal threshold to activate
      .activeOffsetY([-20, 20]) // Vertical threshold to activate
      .failOffsetX([-50, 50]) // Horizontal offset that causes FAIL
      .failOffsetY([-50, 50]) // Vertical offset that causes FAIL
      .averageTouches(true) // Average multi-finger position
      .enableTrackpadTwoFingerGesture(true); // iPad trackpad support
    ```
    
    ---
    
    ## Tap Configuration Cheat Sheet
    
    ```typescript
    Gesture.Tap()
      .numberOfTaps(2) // Required taps (default: 1)
      .maxDuration(500) // Max ms per tap (default: 500)
      .maxDelay(500) // Max ms between taps (default: 500)
      .maxDistance(10) // Max finger movement between taps
      .minPointers(1); // Min simultaneous fingers per tap
    ```
    
    ---
    
    ## ReanimatedSwipeable Props Reference
    
    | Prop                             | Type     | Default | Purpose                                    |
    | -------------------------------- | -------- | ------- | ------------------------------------------ |
    | `friction`                       | number   | 1       | Drag resistance (higher = slower response) |
    | `leftThreshold`                  | number   | half    | Distance to auto-open left panel           |
    | `rightThreshold`                 | number   | half    | Distance to auto-open right panel          |
    | `overshootLeft`                  | boolean  | true    | Allow pulling past left panel width        |
    | `overshootRight`                 | boolean  | true    | Allow pulling past right panel width       |
    | `overshootFriction`              | number   | 1       | Resistance during overshoot (try 8+)       |
    | `dragOffsetFromLeftEdge`         | number   | 10      | Min drag distance to start left swipe      |
    | `dragOffsetFromRightEdge`        | number   | 10      | Min drag distance to start right swipe     |
    | `enableTrackpadTwoFingerGesture` | boolean  | false   | iPad trackpad two-finger swiping           |
    | `renderLeftActions`              | function | --      | Render left action panel                   |
    | `renderRightActions`             | function | --      | Render right action panel                  |
    | `onSwipeableOpen`                | callback | --      | Panel fully opened                         |
    | `onSwipeableClose`               | callback | --      | Panel fully closed                         |
    | `onSwipeableWillOpen`            | callback | --      | Open animation starts                      |
    | `onSwipeableWillClose`           | callback | --      | Close animation starts                     |
    
    ### Ref Methods
    
    | Method        | Purpose                          |
    | ------------- | -------------------------------- |
    | `close()`     | Close swipeable programmatically |
    | `openLeft()`  | Open left panel                  |
    | `openRight()` | Open right panel                 |
    | `reset()`     | Reset state without animation    |
    
  • SKILL.md 16.9 KB
    ---
    name: mobile-animation-gesture-handler
    description: React Native Gesture Handler - gesture types, GestureDetector, gesture composition, state machine, platform-specific gestures, swipeable rows, hover gestures
    ---
    
    # React Native Gesture Handler Patterns
    
    > **Quick Guide:** Use Gesture Handler's v2 builder API (`Gesture.Pan()`, `Gesture.Tap()`, etc.) with `GestureDetector` for all touch interactions. Wrap your app root in `GestureHandlerRootView`. Compose gestures with `Gesture.Simultaneous()`, `Gesture.Race()`, and `Gesture.Exclusive()`. Use `onChange` (not `onUpdate`) when working with animation shared values -- `onChange` provides deltas (`changeX`), `onUpdate` provides cumulative values (`translationX`). Gesture callbacks are automatically workletized when your animation library is installed.
    
    ---
    
    <critical_requirements>
    
    ## CRITICAL: Before Using This Skill
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST wrap the app root in `GestureHandlerRootView` -- gestures will silently fail without it)**
    
    **(You MUST use `GestureDetector` with the builder API (`Gesture.Pan()`, etc.) -- NOT the legacy `PanGestureHandler` components)**
    
    **(You MUST use `onChange` for incremental shared value updates and `onUpdate` for cumulative values -- mixing them causes drift)**
    
    **(You MUST use `Gesture.Simultaneous()` for multi-touch interactions (pinch + pan) -- without it, only one gesture activates)**
    
    **(You MUST NOT nest `GestureDetector` components using different API styles (hooks vs builder) under the same root -- this causes undefined behavior)**
    
    </critical_requirements>
    
    ---
    
    **Auto-detection:** react-native-gesture-handler, GestureDetector, GestureHandlerRootView, Gesture.Pan, Gesture.Tap, Gesture.Pinch, Gesture.Rotation, Gesture.LongPress, Gesture.Fling, Gesture.Hover, Gesture.Simultaneous, Gesture.Race, Gesture.Exclusive, Swipeable, ReanimatedSwipeable, onBegin, onStart, onChange, onUpdate, onEnd, onFinalize
    
    **When to use:**
    
    - Adding pan, pinch, tap, rotation, long-press, fling, or hover gestures to React Native views
    - Composing multiple gestures on the same view (pinch-to-zoom + drag)
    - Building swipeable list rows with reveal actions
    - Replacing React Native's built-in Gesture Responder System (PanResponder)
    - Implementing gesture-driven animations with shared values
    - Adding hover interactions for iPad trackpad, desktop, or web targets
    
    **Key patterns covered:**
    
    - GestureDetector + builder API for all gesture types
    - Gesture composition: Simultaneous, Race, Exclusive
    - Gesture state machine and lifecycle callbacks
    - Shared value integration in gesture callbacks (onChange vs onUpdate)
    - ReanimatedSwipeable for swipeable list rows
    - Hover gesture for pointer devices (iPad trackpad, mouse, stylus)
    - Platform-specific gesture configuration (Android ripple, iOS haptics)
    - Cross-component gesture relations (requireToFail, simultaneousWith, block)
    
    **When NOT to use:**
    
    - Simple button taps (use `Pressable` or `TouchableOpacity` from React Native core)
    - Scroll-only interactions (use `ScrollView` or `FlatList` directly)
    - Web-only applications without React Native
    
    **Detailed Resources:**
    
    - [examples/core.md](examples/core.md) - GestureDetector setup, pan, tap, pinch, rotation gestures with shared values
    - [examples/composition.md](examples/composition.md) - Simultaneous, Race, Exclusive composition, cross-component relations
    - [examples/swipeable.md](examples/swipeable.md) - ReanimatedSwipeable rows, FlatList integration, action panels
    - [examples/advanced.md](examples/advanced.md) - Hover gesture, manual gesture control, platform-specific config
    - [reference.md](reference.md) - Decision frameworks, gesture type reference, state machine diagram
    
    ---
    
    <philosophy>
    
    ## Philosophy
    
    React Native Gesture Handler replaces the built-in Gesture Responder System with native-driven gesture recognition. The key advantage is that gestures are processed on the native thread, not JS -- so they remain responsive even when JS is busy.
    
    **Core principles:**
    
    1. **Native-first** -- Gesture recognition runs natively; callbacks optionally run as worklets on the UI thread
    2. **Declarative composition** -- Define gestures as objects, compose them with `Simultaneous`, `Race`, `Exclusive`
    3. **State machine driven** -- Every gesture follows UNDETERMINED -> BEGAN -> ACTIVE -> END/FAILED/CANCELLED
    4. **Builder API** -- Chain configuration methods: `Gesture.Pan().minDistance(10).onUpdate(handler)`
    5. **One GestureDetector per gesture (or composed gesture)** -- Don't attach multiple unrelated gestures via separate nested detectors
    
    **Mental model:** Think of each gesture as a state machine that competes with other gestures for activation. Composition methods (`Simultaneous`, `Race`, `Exclusive`) define the competition rules. The gesture that wins transitions to ACTIVE; the rest FAIL or get CANCELLED.
    
    **v3 hooks API (beta):** RNGH v3 introduces a hooks-based API (`usePanGesture`, `useSimultaneousGestures`, etc.) that is cleaner but still in beta. The v2 builder API (`Gesture.Pan()`, `GestureDetector`) is the stable production API documented here.
    
    </philosophy>
    
    ---
    
    <patterns>
    
    ## Core Patterns
    
    ### Pattern 1: GestureHandlerRootView Setup
    
    Every app using Gesture Handler must wrap its root in `GestureHandlerRootView`. Without it, gestures silently fail -- no errors, just no recognition.
    
    ```typescript
    import { GestureHandlerRootView } from "react-native-gesture-handler";
    
    export function App() {
      return (
        <GestureHandlerRootView style={{ flex: 1 }}>
          <AppContent />
        </GestureHandlerRootView>
      );
    }
    ```
    
    **Why good:** single root wrapper, `flex: 1` ensures full-screen coverage
    
    **Gotcha (Android modals):** React Native Modals on Android create a separate native view hierarchy. Wrap Modal content in its own `GestureHandlerRootView` -- gestures inside a Modal won't work otherwise.
    
    **Gotcha (native navigation):** When using a native navigation library (separate screen containers per screen), wrap each registered screen individually rather than a single app-level root.
    
    See [examples/core.md](examples/core.md) for the full setup pattern.
    
    ---
    
    ### Pattern 2: Pan Gesture with Shared Values
    
    The most common pattern: drag an element by tracking translation deltas.
    
    ```typescript
    const offset = useSharedValue(0);
    
    const pan = Gesture.Pan()
      .onChange((event) => {
        offset.value += event.changeX; // Incremental delta
      })
      .onFinalize((event) => {
        offset.value = withDecay({ velocity: event.velocityX });
      });
    
    // Wrap with GestureDetector + Animated.View
    <GestureDetector gesture={pan}>
      <Animated.View style={animatedStyle} />
    </GestureDetector>
    ```
    
    **Why `onChange` over `onUpdate`:** `onChange` provides `changeX`/`changeY` (delta since last event) -- add it to an offset. `onUpdate` provides `translationX`/`translationY` (cumulative since gesture start) -- use it as an absolute position. Mixing them causes position drift.
    
    See [examples/core.md](examples/core.md) for complete pan, tap, pinch, and rotation examples.
    
    ---
    
    ### Pattern 3: Gesture Composition
    
    Compose multiple gestures on the same view with three strategies:
    
    ```typescript
    const pan = Gesture.Pan().onChange(/* ... */);
    const pinch = Gesture.Pinch().onUpdate(/* ... */);
    const rotation = Gesture.Rotation().onUpdate(/* ... */);
    
    // All three active simultaneously (photo viewer)
    const composed = Gesture.Simultaneous(pan, pinch, rotation);
    
    // First to activate wins, rest fail (swipe vs long-press)
    const racing = Gesture.Race(pan, longPress);
    
    // Priority order: first arg wins ties (double-tap beats single-tap)
    const exclusive = Gesture.Exclusive(doubleTap, singleTap);
    
    <GestureDetector gesture={composed}>
      <Animated.View />
    </GestureDetector>
    ```
    
    **Key rule:** Pass the composed gesture to a single `GestureDetector`. Do not nest multiple `GestureDetector` components for gestures that should interact -- use composition instead.
    
    See [examples/composition.md](examples/composition.md) for full photo viewer and tap disambiguation patterns.
    
    ---
    
    ### Pattern 4: Gesture State Machine
    
    Every gesture follows a state machine. Understanding it is critical for debugging gesture conflicts:
    
    ```
    UNDETERMINED ──> BEGAN ──> ACTIVE ──> END ──> UNDETERMINED
                      │                    │
                      ├──> FAILED ─────────┘
                      │                    │
                      └──> (ACTIVE) ──> CANCELLED ──> UNDETERMINED
    ```
    
    **Lifecycle callbacks map to states:**
    
    | Callback     | When it fires                                              |
    | ------------ | ---------------------------------------------------------- |
    | `onBegin`    | UNDETERMINED -> BEGAN (touch detected, not yet recognized) |
    | `onStart`    | BEGAN -> ACTIVE (gesture recognized, meets criteria)       |
    | `onUpdate`   | While ACTIVE (cumulative: `translationX`, `scale`)         |
    | `onChange`   | While ACTIVE (incremental: `changeX`, `changeY`)           |
    | `onEnd`      | ACTIVE -> END (finger lifted normally)                     |
    | `onFinalize` | Fires for ANY terminal state (END, FAILED, CANCELLED)      |
    
    **`onEnd` vs `onFinalize`:** Use `onEnd` for success-only cleanup (save position). Use `onFinalize` for guaranteed cleanup (reset state regardless of outcome). `onFinalize` receives a second `success` boolean parameter.
    
    See [reference.md](reference.md) for the complete state transition diagram.
    
    ---
    
    ### Pattern 5: Cross-Component Gesture Relations
    
    When gestures live on different components (parent scroll + child drag), use relation methods:
    
    ```typescript
    const childPan = Gesture.Pan();
    const parentScroll = Gesture.Native(); // Wraps native ScrollView gesture
    
    // Child drag must fail before parent scroll activates
    parentScroll.requireExternalGestureToFail(childPan);
    
    // Or: both active simultaneously
    childPan.simultaneousWith(parentScroll);
    
    // Or: child blocks parent while active
    childPan.blocksExternalGesture(parentScroll);
    ```
    
    **Key difference from composition:** `Gesture.Simultaneous()` composes gestures on the same component. `.simultaneousWith()` relates gestures across different components in the view hierarchy.
    
    See [examples/composition.md](examples/composition.md) for cross-component patterns.
    
    ---
    
    ### Pattern 6: ReanimatedSwipeable for List Rows
    
    Use `ReanimatedSwipeable` (not the legacy `Swipeable`) for swipeable list items with smooth 60fps animations.
    
    ```typescript
    import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
    
    const SWIPE_THRESHOLD = 40;
    const OVERSHOOT_FRICTION = 8;
    
    <ReanimatedSwipeable
      friction={2}
      rightThreshold={SWIPE_THRESHOLD}
      overshootFriction={OVERSHOOT_FRICTION}
      renderRightActions={renderRightActions}
      onSwipeableOpen={handleDelete}
    >
      <ListItemContent />
    </ReanimatedSwipeable>
    ```
    
    **Why ReanimatedSwipeable over Swipeable:** Rewritten with worklets for native-thread animations. The legacy `Swipeable` animates on JS thread and drops frames during heavy renders.
    
    See [examples/swipeable.md](examples/swipeable.md) for full implementation with action panels and FlatList integration.
    
    ---
    
    ### Pattern 7: Hover Gesture (Pointer Devices)
    
    Hover gesture detects mouse/stylus/trackpad hovering -- useful for iPad with trackpad, macOS, and web targets. Does not fire on phone touch.
    
    ```typescript
    const hover = Gesture.Hover()
      .onBegin(() => {
        isHovered.value = true;
      })
      .onEnd(() => {
        isHovered.value = false;
      });
    ```
    
    **Platform support:** iOS (iPad with trackpad/mouse), macOS, web. Does NOT work on Android touch or iPhone touch. On iOS, the optional `hoverEffect` prop on `GestureDetector` provides system-level visual effects (highlight, lift, automatic).
    
    See [examples/advanced.md](examples/advanced.md) for hover with visual effects.
    
    </patterns>
    
    ---
    
    <decision_framework>
    
    ## Decision Framework
    
    ### Which Gesture Type?
    
    ```
    What user interaction are you handling?
    ├─ Drag/move element         → Gesture.Pan()
    ├─ Single tap                → Gesture.Tap()
    ├─ Double tap                → Gesture.Tap().numberOfTaps(2)
    ├─ Long press                → Gesture.LongPress()
    ├─ Pinch to zoom             → Gesture.Pinch()
    ├─ Two-finger rotate         → Gesture.Rotation()
    ├─ Quick directional swipe   → Gesture.Fling()
    ├─ Mouse/trackpad hover      → Gesture.Hover()
    └─ Wrap a native gesture     → Gesture.Native() (for ScrollView, etc.)
    ```
    
    ### Which Composition?
    
    ```
    How should multiple gestures interact?
    ├─ All active at once (pan + pinch + rotate in photo viewer)
    │   └─ Gesture.Simultaneous(gesture1, gesture2, ...)
    ├─ First to activate wins (swipe vs long-press on same view)
    │   └─ Gesture.Race(gesture1, gesture2, ...)
    ├─ Priority order (double-tap must beat single-tap)
    │   └─ Gesture.Exclusive(highPriority, lowPriority)
    └─ Gestures on DIFFERENT components (child drag vs parent scroll)
        ├─ Parent waits for child to fail → parent.requireExternalGestureToFail(child)
        ├─ Both active simultaneously     → child.simultaneousWith(parent)
        └─ Child blocks parent            → child.blocksExternalGesture(parent)
    ```
    
    ### onChange vs onUpdate?
    
    ```
    How are you using the gesture position data?
    ├─ Adding to an offset (shared value += delta)
    │   └─ Use onChange (provides changeX, changeY)
    ├─ Setting absolute position from gesture start
    │   └─ Use onUpdate (provides translationX, translationY)
    └─ Need both (rare)
        └─ Use onChange for offsets + onUpdate for display values
    ```
    
    </decision_framework>
    
    ---
    
    <red_flags>
    
    ## RED FLAGS
    
    **High Priority Issues:**
    
    - Missing `GestureHandlerRootView` at app root -- gestures silently fail with no error message
    - Using legacy `PanGestureHandler`/`TapGestureHandler` components -- deprecated, use `Gesture.Pan()` + `GestureDetector`
    - Using `onUpdate` to add deltas to shared values -- `onUpdate` provides cumulative values, not deltas; use `onChange` for incremental updates
    - Nesting multiple `GestureDetector` components for gestures that should compose -- use `Gesture.Simultaneous()`/`Race()`/`Exclusive()` instead
    - Using `React.PanResponder` -- replaced entirely by Gesture Handler; PanResponder runs on JS thread and blocks the UI
    
    **Medium Priority Issues:**
    
    - Not specifying `minDistance` on `Gesture.Pan()` when coexisting with tap gestures -- pan activates immediately at 0px, stealing taps
    - Missing `onFinalize` cleanup -- `onEnd` only fires on success; cancelled/failed gestures skip it
    - Using `Swipeable` instead of `ReanimatedSwipeable` -- legacy component runs animations on JS thread
    - Creating new gesture instances inside render (not in useMemo or outside component) -- causes gesture to reset every render
    
    **Gotchas & Edge Cases:**
    
    - `Gesture.Exclusive(doubleTap, singleTap)` -- double-tap MUST be the first argument (higher priority) or it will never fire because single-tap activates first
    - Android modals need their own `GestureHandlerRootView` -- gestures in modals fail silently without it
    - `onChange` callback is NOT available in the v3 hooks API -- it was removed; use `onUpdate` with the `change*` properties on the event object instead
    - Gesture callbacks are automatically workletized when your animation library is installed -- don't manually add `"worklet"` directives unless you need them without the animation library
    - `Gesture.Native()` wraps platform-native gestures (ScrollView, FlatList) -- use it when you need to create relations between custom gestures and native scrolling
    - `onFinalize` receives `(event, success)` -- the `success` boolean tells you whether the gesture ended normally (END) or was cancelled/failed
    - Reusing the same gesture instance across multiple `GestureDetector` components causes undefined behavior -- create separate instances
    - iPad trackpad two-finger gestures require `enableTrackpadTwoFingerGesture` on both Pan and `ReanimatedSwipeable`
    - `hoverEffect` prop on GestureDetector only works on iOS 17.0+ and provides system-level visual effects
    - `Gesture.Fling()` only fires at the end of the fling, not continuously -- use `Gesture.Pan()` if you need continuous position tracking during a swipe
    
    </red_flags>
    
    ---
    
    <critical_reminders>
    
    ## CRITICAL REMINDERS
    
    > **All code must follow project conventions in CLAUDE.md**
    
    **(You MUST wrap the app root in `GestureHandlerRootView` -- gestures will silently fail without it)**
    
    **(You MUST use `GestureDetector` with the builder API (`Gesture.Pan()`, etc.) -- NOT the legacy `PanGestureHandler` components)**
    
    **(You MUST use `onChange` for incremental shared value updates and `onUpdate` for cumulative values -- mixing them causes drift)**
    
    **(You MUST use `Gesture.Simultaneous()` for multi-touch interactions (pinch + pan) -- without it, only one gesture activates)**
    
    **(You MUST NOT nest `GestureDetector` components using different API styles (hooks vs builder) under the same root -- this causes undefined behavior)**
    
    **Failure to follow these rules will cause silent gesture failures, animation drift, and broken multi-touch interactions.**
    
    </critical_reminders>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related