Skip to main content

React Native Gesture Handler v2 vs PanResponder vs Pressable 2026

·PkgPulse Team

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+)

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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.