Skip to main content

Guide

Gesture Handler v2 vs PanResponder vs Pressable (2026)

React Native Gesture Handler v2 vs PanResponder vs Pressable compared for mobile gesture handling. Swipes, drags, pinch-to-zoom, gesture composition, and.

·PkgPulse Team·
0

React Native Gesture Handler v2 vs PanResponder vs Pressable 2026

TL;DR

Gesture handling in React Native has three layers: built-in primitives for simple taps, the legacy PanResponder system for custom gestures, and the modern Gesture Handler library for complex, composable gestures. React Native Gesture Handler (RNGH) v2 is the modern standard — runs gesture recognition on the UI thread (not the JS thread), has a composable gesture API that handles conflicts automatically, and integrates with Reanimated for jank-free gesture-driven animations. PanResponder is the built-in React Native gesture system — works without any extra library, handles the onPanResponderMove lifecycle, but runs on the JS thread leading to frame drops when combined with animations. Pressable (and TouchableOpacity/Touchable*) are the simplest option — tap gestures only, perfect for buttons and tappable elements, with built-in visual feedback; no swipe/drag/pinch support. For complex custom gestures with smooth animations: RNGH v2 + Reanimated. For simple taps and press feedback: Pressable. For legacy code or gesture-without-dependencies: PanResponder.

Key Takeaways

  • RNGH v2 runs on UI thread — gesture recognition happens natively, no JS bridge involved
  • PanResponder runs on JS thread — can drop frames during heavy gesture + animation combos
  • RNGH v2 has gesture compositionGesture.Simultaneous(), Gesture.Exclusive(), Gesture.Race()
  • Pressable is built-in — zero dependencies; handles onPress, onLongPress, onPressIn/Out
  • RNGH v2 + Reanimated = worklet — gesture values (translationX) available in Reanimated worklets
  • PanResponder onMoveShouldSetPanResponder — must manually discriminate gestures
  • RNGH requires GestureHandlerRootView — wrap your app root once

When to Use Each

Button / tap / press feedback    → Pressable (simplest, no deps)
Long press                       → Pressable onLongPress
Simple swipe to dismiss          → RNGH v2 (PanGesture + Reanimated)
Draggable card / sortable list   → RNGH v2 (PanGesture)
Pinch-to-zoom                    → RNGH v2 (PinchGesture)
Rotation gesture                 → RNGH v2 (RotationGesture)
Two-finger gestures simultaneously → RNGH v2 (Gesture.Simultaneous)
ScrollView vs swipeable conflict → RNGH v2 (gesture discrimination)
Legacy RN codebase               → PanResponder or RNGH v1 (keep existing)

React Native Gesture Handler v2

RNGH v2 introduced a declarative API where gestures are objects composed together — replacing the callback-heavy v1 API.

Installation

npm install react-native-gesture-handler react-native-reanimated
npx pod-install  # iOS

# babel.config.js — add Reanimated plugin
# plugins: ['react-native-reanimated/plugin']

Setup

// app/_layout.tsx or App.tsx
import { GestureHandlerRootView } from "react-native-gesture-handler";

export default function RootLayout() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      {/* App content */}
    </GestureHandlerRootView>
  );
}

Pan Gesture (Drag)

import { GestureDetector, Gesture } from "react-native-gesture-handler";
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from "react-native-reanimated";

export function DraggableCard() {
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);

  const panGesture = Gesture.Pan()
    .onUpdate((event) => {
      // Runs on UI thread — no JS bridge
      translateX.value = event.translationX;
      translateY.value = event.translationY;
    })
    .onEnd(() => {
      // Snap back to origin
      translateX.value = withSpring(0);
      translateY.value = withSpring(0);
    });

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: translateX.value },
      { translateY: translateY.value },
    ],
  }));

  return (
    <GestureDetector gesture={panGesture}>
      <Animated.View
        style={[
          {
            width: 200,
            height: 100,
            backgroundColor: "#3b82f6",
            borderRadius: 12,
            justifyContent: "center",
            alignItems: "center",
          },
          animatedStyle,
        ]}
      >
        <Animated.Text style={{ color: "#fff", fontWeight: "bold" }}>
          Drag me
        </Animated.Text>
      </Animated.View>
    </GestureDetector>
  );
}

Swipe to Dismiss

import { GestureDetector, Gesture } from "react-native-gesture-handler";
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  runOnJS,
} from "react-native-reanimated";
import { Dimensions } from "react-native";

const SCREEN_WIDTH = Dimensions.get("window").width;
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.4;

export function SwipeToDismissCard({ onDismiss }: { onDismiss: () => void }) {
  const translateX = useSharedValue(0);
  const opacity = useSharedValue(1);

  const panGesture = Gesture.Pan()
    .activeOffsetX([-10, 10])   // Horizontal swipe only
    .failOffsetY([-20, 20])     // Fail if vertical
    .onUpdate((event) => {
      translateX.value = event.translationX;
      opacity.value = 1 - Math.abs(event.translationX) / SCREEN_WIDTH;
    })
    .onEnd((event) => {
      if (Math.abs(event.translationX) > SWIPE_THRESHOLD) {
        // Dismiss — animate off screen, then call onDismiss on JS thread
        const direction = event.translationX > 0 ? 1 : -1;
        translateX.value = withTiming(direction * SCREEN_WIDTH * 1.5, {}, () => {
          runOnJS(onDismiss)();  // Call JS function from UI thread worklet
        });
      } else {
        // Snap back
        translateX.value = withSpring(0);
        opacity.value = withSpring(1);
      }
    });

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }],
    opacity: opacity.value,
  }));

  return (
    <GestureDetector gesture={panGesture}>
      <Animated.View style={[styles.card, animatedStyle]}>
        <Text>Swipe to dismiss</Text>
      </Animated.View>
    </GestureDetector>
  );
}

Pinch to Zoom

import { GestureDetector, Gesture } from "react-native-gesture-handler";
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";

export function ZoomableImage({ source }: { source: { uri: string } }) {
  const scale = useSharedValue(1);
  const savedScale = useSharedValue(1);

  const pinchGesture = Gesture.Pinch()
    .onUpdate((event) => {
      scale.value = savedScale.value * event.scale;
    })
    .onEnd(() => {
      savedScale.value = scale.value;
    });

  const doubleTapGesture = Gesture.Tap()
    .numberOfTaps(2)
    .onEnd(() => {
      // Double tap to reset zoom
      if (scale.value > 1) {
        scale.value = withSpring(1);
        savedScale.value = 1;
      } else {
        scale.value = withSpring(2);
        savedScale.value = 2;
      }
    });

  // Combine pinch and double-tap simultaneously
  const composed = Gesture.Simultaneous(pinchGesture, doubleTapGesture);

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));

  return (
    <GestureDetector gesture={composed}>
      <Animated.Image
        source={source}
        style={[{ width: 300, height: 300 }, animatedStyle]}
        resizeMode="contain"
      />
    </GestureDetector>
  );
}

Gesture Composition

import { Gesture, GestureDetector } from "react-native-gesture-handler";

// Long press to activate, then pan to move
function LongPressDraggable() {
  const isActive = useSharedValue(false);
  const scale = useSharedValue(1);
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);

  const longPressGesture = Gesture.LongPress()
    .minDuration(500)
    .onStart(() => {
      isActive.value = true;
      scale.value = withSpring(1.1);  // Scale up to indicate active
    });

  const panGesture = Gesture.Pan()
    .manualActivation(true)   // Only activate if long press is already active
    .onTouchesMove((_, state) => {
      if (isActive.value) {
        state.activate();     // Allow pan to proceed
      } else {
        state.fail();         // Fail pan if long press not active
      }
    })
    .onUpdate((event) => {
      translateX.value = event.translationX;
      translateY.value = event.translationY;
    })
    .onEnd(() => {
      isActive.value = false;
      scale.value = withSpring(1);
      translateX.value = withSpring(0);
      translateY.value = withSpring(0);
    });

  // Run both gestures simultaneously
  const composed = Gesture.Simultaneous(longPressGesture, panGesture);

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: translateX.value },
      { translateY: translateY.value },
      { scale: scale.value },
    ],
  }));

  return (
    <GestureDetector gesture={composed}>
      <Animated.View style={[styles.item, animatedStyle]}>
        <Text>Long press then drag</Text>
      </Animated.View>
    </GestureDetector>
  );
}

PanResponder: Built-in JS Thread Gestures

PanResponder is React Native's built-in gesture system — no external library required, but runs on the JS thread.

Basic Pan Gesture

import { PanResponder, Animated, View, StyleSheet } from "react-native";
import { useRef } from "react";

export function DraggableItem() {
  const pan = useRef(new Animated.ValueXY()).current;

  const panResponder = useRef(
    PanResponder.create({
      // Should this view become the responder?
      onMoveShouldSetPanResponder: () => true,

      // Called on every move event
      onPanResponderMove: Animated.event(
        [null, { dx: pan.x, dy: pan.y }],
        { useNativeDriver: false }  // Cannot use native driver with ValueXY
      ),

      // Called when gesture ends
      onPanResponderRelease: () => {
        Animated.spring(pan, {
          toValue: { x: 0, y: 0 },
          useNativeDriver: false,
        }).start();
      },
    })
  ).current;

  return (
    <Animated.View
      style={[
        styles.card,
        {
          transform: [{ translateX: pan.x }, { translateY: pan.y }],
        },
      ]}
      {...panResponder.panHandlers}
    >
      <View><Text>Drag me (PanResponder)</Text></View>
    </Animated.View>
  );
}

Swipe Detection

import { PanResponder, View, Text } from "react-native";

export function SwipeableCard({ onSwipeLeft, onSwipeRight }: {
  onSwipeLeft: () => void;
  onSwipeRight: () => void;
}) {
  const SWIPE_THRESHOLD = 120;
  const SWIPE_VELOCITY = 0.3;

  const panResponder = PanResponder.create({
    onMoveShouldSetPanResponder: (_, gestureState) =>
      Math.abs(gestureState.dx) > 5,  // Horizontal movement > 5px

    onPanResponderRelease: (_, gestureState) => {
      if (
        gestureState.dx > SWIPE_THRESHOLD ||
        gestureState.vx > SWIPE_VELOCITY
      ) {
        onSwipeRight();
      } else if (
        gestureState.dx < -SWIPE_THRESHOLD ||
        gestureState.vx < -SWIPE_VELOCITY
      ) {
        onSwipeLeft();
      }
    },
  });

  return (
    <View {...panResponder.panHandlers} style={styles.card}>
      <Text>Swipe left or right</Text>
    </View>
  );
}

Pressable: Simple Tap Gestures

The built-in component for taps, presses, and long presses — the right tool for buttons and interactive elements.

Basic Pressable

import { Pressable, Text, View } from "react-native";

export function PrimaryButton({
  onPress,
  label,
  disabled = false,
}: {
  onPress: () => void;
  label: string;
  disabled?: boolean;
}) {
  return (
    <Pressable
      onPress={onPress}
      onLongPress={() => console.log("Long pressed!")}
      disabled={disabled}
      style={({ pressed }) => [
        {
          backgroundColor: pressed ? "#2563eb" : "#3b82f6",
          borderRadius: 8,
          padding: 14,
          alignItems: "center",
          opacity: disabled ? 0.5 : 1,
          transform: [{ scale: pressed ? 0.97 : 1 }],
        },
      ]}
      hitSlop={8}   // Extend tap target by 8px on all sides
    >
      <Text style={{ color: "#fff", fontWeight: "bold" }}>{label}</Text>
    </Pressable>
  );
}

Press In/Out Animation (Reanimated)

import { Pressable } from "react-native";
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSpring,
} from "react-native-reanimated";

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);

export function SpringButton({ onPress, children }: {
  onPress: () => void;
  children: React.ReactNode;
}) {
  const scale = useSharedValue(1);

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));

  return (
    <AnimatedPressable
      onPressIn={() => {
        scale.value = withSpring(0.95, { damping: 15, stiffness: 400 });
      }}
      onPressOut={() => {
        scale.value = withSpring(1, { damping: 15, stiffness: 400 });
      }}
      onPress={onPress}
      style={[styles.button, animatedStyle]}
    >
      {children}
    </AnimatedPressable>
  );
}

Feature Comparison

FeatureRNGH v2PanResponderPressable
ThreadUI thread (native)JS threadNative
DependencyExternal packageBuilt-inBuilt-in
Gesture typesPan, Pinch, Rotation, Tap, Fling, LongPressPan only (manual)Tap, LongPress only
Gesture composition✅ Simultaneous, Exclusive, Race❌ (manual)
Reanimated integration✅ Native worklets⚠️ Limited✅ (via createAnimatedComponent)
Conflict resolution✅ Automatic❌ Manual✅ Built-in
Multi-touch⚠️ Complex
Pinch/Zoom✅ PinchGesture
ScrollView integration❌ (conflicts)
Setup complexityMediumLowNone
Expo Go✅ (Expo SDK 50+)

Production Performance: UI Thread vs JS Thread

The most important technical distinction between RNGH v2 and PanResponder is which thread processes gesture events. React Native runs JavaScript on a separate thread from the UI (main) thread — when gesture events cross from native to JavaScript via the bridge, there's inherent latency. Under normal conditions this is imperceptible, but when the JavaScript thread is busy (large list renders, Redux store updates, network response processing), PanResponder gesture events are delayed, causing visible lag in gesture-driven animations. RNGH v2 runs gesture recognition entirely on the UI thread via native gesture recognizers — the gesture state machine never touches JavaScript until the gesture is complete and you need to call a JS function via runOnJS. Combined with Reanimated's worklets (also running on the UI thread), gesture-driven animations remain buttery smooth at 60fps even when JavaScript is processing heavy operations. For production apps with complex gesture interactions, this thread architecture difference is decisive.

Gesture Conflict Resolution in Complex Layouts

Nested scroll views, bottom sheets, and horizontally swipeable lists inside vertical scroll views are the canonical gesture conflict scenarios in React Native. PanResponder's conflict resolution requires manual discrimination in onMoveShouldSetPanResponder — you inspect gesture velocity and displacement to decide whether the horizontal or vertical gesture should win, and bugs here cause the "wrong" gesture to activate unpredictably. RNGH v2's .activeOffsetX(), .failOffsetY(), and .simultaneousWithExternalGesture() configuration methods encode conflict resolution declaratively. The Gesture.Exclusive() compositor ensures only one gesture activates at a time, while Gesture.Simultaneous() allows multiple gestures to recognize concurrently. For bottom sheet components specifically — a common RNGH v2 use case — the useAnimatedScrollHandler and scroll view gesture interop pattern lets the bottom sheet pan gesture coexist with the scroll view's own scroll gesture, with the correct gesture winning based on scroll position.

Expo Compatibility and Installation Considerations

RNGH v2 is included in Expo SDK 50+ and available in Expo Go without any native module installation — a significant reduction in friction compared to earlier versions that required bare workflow. For Expo managed projects, npx expo install react-native-gesture-handler handles version compatibility automatically. The GestureHandlerRootView wrapper is required exactly once at the app root; missing this wrapper causes gestures to silently fail rather than producing an obvious error message, which is a common gotcha for new users. Reanimated (required for smooth gesture-driven animations) also ships with Expo SDK 50+ and works in Expo Go, meaning the full RNGH v2 + Reanimated stack is available to managed Expo projects without ejecting. For bare React Native, both libraries require npx pod-install on iOS and Gradle sync on Android, and the Babel plugin for Reanimated must be added to babel.config.js or animations will silently fall back to the JS thread.

Testing Gesture-Driven Components

Testing components that depend on RNGH v2 requires mock setup that many teams overlook. The @testing-library/react-native integration with RNGH v2 requires the react-native-gesture-handler/jestSetup module imported in your Jest setup file — without this, gesture detector components throw during test runs. For integration tests that need to simulate actual gesture sequences (fire a pan, expect a position change), @testing-library/react-native's fireEvent doesn't support gesture sequences; teams use react-native-testing-library's userEvent or manually trigger the gesture handler's state machine. Reanimated tests require the react-native-reanimated/mock module, which replaces animated values with mock implementations. The combination of these mocks makes unit and integration testing gesture-driven components feasible, though testing the visual smoothness of animations requires device-based testing or snapshot testing of final positions rather than intermediate frames.

Community Ecosystem and Long-Term Maintenance

RNGH v2 is maintained by Software Mansion (the same team behind Reanimated and React Native Screens) and is the foundational gesture layer for the broader React Native component ecosystem. Libraries like react-native-bottom-sheet by Gorhom, react-native-swipe-list-view, react-native-sortable-list, and dozens of other community libraries depend on RNGH v2 as a peer dependency. This centralization means most production React Native apps already include RNGH v2 as a transitive dependency — adding direct usage has no incremental installation cost. PanResponder, being a React Native built-in, will remain available indefinitely but receives no new feature development; it's effectively in maintenance mode. Pressable and the Touchable family are actively maintained as the preferred API for tap interactions. For new React Native projects in 2026, the three-layer approach (Pressable for taps, RNGH v2 for custom gestures, PanResponder only for legacy compatibility) is the community consensus.

When to Use Each

Choose RNGH v2 if:

  • Custom gesture: drag-and-drop, swipe to dismiss, pull-to-refresh, panning
  • Multi-touch: pinch to zoom, rotation
  • Smooth gesture + animation (Reanimated worklets on UI thread)
  • Gesture conflict resolution needed (drag inside scroll, bottom sheet pan vs scroll)
  • Building list items, cards, or drawers with gesture-driven interactions

Choose PanResponder if:

  • Simple swipe detection without RNGH setup
  • Legacy codebase already using PanResponder
  • Very basic gesture without complex animation (simple direction detection)
  • Don't want Reanimated/RNGH as peer dependencies

Choose Pressable if:

  • Buttons, links, list items, touchable cards — any simple tap
  • Built-in visual feedback via style function with pressed state
  • onLongPress for context menu triggers
  • Expo Go compatible, zero additional dependencies needed

Methodology

Data sourced from React Native Gesture Handler documentation (docs.swmansion.com/react-native-gesture-handler), React Native PanResponder documentation (reactnative.dev/docs/panresponder), Pressable documentation (reactnative.dev/docs/pressable), npm download statistics as of February 2026, GitHub star counts as of February 2026, and community discussions from the React Native Discord and r/reactnative.


Related: Gorhom Bottom Sheet vs Expo Bottom Sheet vs RNSBS for a prime use case of RNGH v2 in action, or React Native Reanimated vs Moti vs Skia for the animation libraries that power gesture-driven animations.

See also: React vs Vue and React vs Svelte

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.