React Native Gesture Handler v2 vs PanResponder vs Pressable 2026
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 composition —
Gesture.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
| Feature | RNGH v2 | PanResponder | Pressable |
|---|---|---|---|
| Thread | UI thread (native) | JS thread | Native |
| Dependency | External package | Built-in | Built-in |
| Gesture types | Pan, Pinch, Rotation, Tap, Fling, LongPress | Pan 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 complexity | Medium | Low | None |
| 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
stylefunction withpressedstate onLongPressfor 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.