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+) | ✅ | ✅ |
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
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.
See also: React vs Vue and React vs Svelte