Why not just use PanResponder
PanResponder ships with React Native and needs no install, which is exactly why so many tutorials use it. The problem is where it runs. Every touch event crosses into JavaScript, your handler runs there, and the resulting style update crosses back. When the JS thread is busy — a list re-rendering, a query resolving, an image decoding — the drag drops frames, and it drops them on the cheap Android device you do not own.
Gesture Handler moves recognition to the native side. Paired with Reanimated worklets, the handler body itself executes on the UI thread, so a drag keeps up with the finger even while JavaScript is fully occupied. That is the whole pitch, and it is worth the two dependencies.
Install, and the root view everyone forgets
npx expo install react-native-gesture-handler react-native-reanimated # bare React Native: npm install react-native-gesture-handler react-native-reanimated && npx pod-install
Then mount the root view once, as high in the tree as you can. In Expo Router that is the root layout:
// app/_layout.tsx
import 'react-native-gesture-handler'; // must be the first import in the entry file
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack />
</GestureHandlerRootView>
);
}Two details, both responsible for a large share of the “my gesture does nothing” reports. style={{ flex: 1 }} is not optional — without it the root view collapses to zero height and swallows nothing, so gestures land on an empty box. And on Android, anything rendered outside a GestureHandlerRootView never receives gesture events at all, silently. That includes content inside a React Native Modal, which renders in its own native window: wrap the modal’s children in their own root view or the gestures inside it will not fire.
A pan gesture, the current way
If a tutorial shows you <PanGestureHandler onGestureEvent={...}> with useAnimatedGestureHandler, it predates the v2 API. The modern shape is a gesture object plus one detector:
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
function DraggableCard() {
const x = useSharedValue(0);
const y = useSharedValue(0);
const start = useSharedValue({ x: 0, y: 0 });
const pan = Gesture.Pan()
.onBegin(() => {
start.value = { x: x.value, y: y.value }; // remember where this drag started
})
.onChange((e) => {
x.value = start.value.x + e.translationX; // runs on the UI thread
y.value = start.value.y + e.translationY;
})
.onFinalize(() => {
x.value = withSpring(0); // fires even if the gesture is cancelled
y.value = withSpring(0);
});
const style = useAnimatedStyle(() => ({
transform: [{ translateX: x.value }, { translateY: y.value }],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={[styles.card, style]} />
</GestureDetector>
);
}Three things in there are worth internalising:
translationXis cumulative for the gesture, not a delta. It measures from the touch-down point, which is why you snapshot the starting offset inonBeginand add. AddingtranslationXto the current value on every frame double-counts and sends the card flying.onFinalize, notonEnd, for cleanup.onEndis skipped when the gesture is cancelled — a parent scroll claiming the touch, a phone call arriving — and a card that never springs back is the result. Put the reset inonFinalize, which always runs.- The child must be animatable.
GestureDetectorattaches to whatever single child you give it, and that child needs to be anAnimated.Viewfor the style to update without a re-render.
Calling JavaScript from a gesture
Handler bodies are worklets. They run on the UI thread, which means they cannot touch your React state, navigation, or anything else that lives in JavaScript. Crossing back is explicit:
import { runOnJS } from 'react-native-reanimated';
const tap = Gesture.Tap()
.maxDuration(250)
.onEnd((_e, success) => {
if (success) runOnJS(router.push)('/details'); // never call router.push directly here
});Forgetting runOnJSusually produces a “tried to synchronously call a non-worklet function on the UI thread” crash, which at least tells you what is wrong. The subtler mistake is doing real work inside the worklet — parsing, sorting, formatting dates — because whatever you put there is now competing with the frame you are trying to keep smooth. Keep worklets to arithmetic on shared values.
Composing gestures
One detector takes one gesture, so multiple gestures on the same view are combined into one object first. The four combinators cover essentially every case:
| API | Behaviour | Typical use |
|---|---|---|
Gesture.Simultaneous(a, b) | Both recognise at once | Pinch plus rotate on a photo viewer |
Gesture.Race(a, b) | First to activate wins, the other is cancelled | Pan or long-press on the same card |
Gesture.Exclusive(a, b) | Tries in order, falls through on failure | Double tap first, single tap as fallback |
.requireExternalGestureToFail(other) | Waits for another gesture to fail | Tap that must lose to the parent swipe |
const doubleTap = Gesture.Tap().numberOfTaps(2).onEnd(() => { scale.value = withSpring(2); });
const singleTap = Gesture.Tap().onEnd(() => { runOnJS(openViewer)(); });
// Double tap gets first refusal; single tap only fires once double has failed.
const taps = Gesture.Exclusive(doubleTap, singleTap);
// Pinch and rotate should both be live at the same time.
const transform = Gesture.Simultaneous(Gesture.Pinch(), Gesture.Rotation());Note what Exclusive costs you: the single tap now waits for the double-tap window to expire, so a plain tap feels slightly delayed. That is inherent to double-tap-plus-single-tap on the same target, not a bug in the library — which is a good reason to avoid the pairing unless the interaction genuinely needs it.
Gestures inside a scrolling list
This is where most real bugs live. A row that swipes horizontally inside a vertically scrolling list means two recognisers want the same finger, and by default the one that activates first wins the whole interaction. The fix is to tell the row gesture when it is allowed to claim the touch and when it should give up:
const swipeRow = Gesture.Pan()
.activeOffsetX([-10, 10]) // only activate after 10px of horizontal movement
.failOffsetY([-8, 8]) // give up immediately if the finger goes vertical
.onChange((e) => { offset.value = Math.min(0, e.translationX); })
.onEnd((e) => {
const shouldOpen = e.translationX < -80 || e.velocityX < -600;
offset.value = withSpring(shouldOpen ? -96 : 0);
});Those two thresholds are the difference between a list that feels native and one that feels sticky. And use the list from the gesture library itself when the rows are interactive: import { FlatList } from 'react-native-gesture-handler' gives you a scroll view that participates in the gesture system rather than competing with it from outside.
Notice the velocity check in onEnd. Distance alone makes a quick flick feel broken, because the user moved fast but not far. Accepting either a distance threshold or a velocity threshold is what makes a swipe feel like the ones in Mail and Messages. If you want the whole row assembled for you, the library ships ReanimatedSwipeable, which handles the action panels, snapping, and programmatic close — worth reading before you rebuild it.
The checklist when a gesture does nothing
- Is there a root view above it, with
flex: 1, including inside any nativeModal? - Is the target actually that size? A view with no explicit dimensions and no content is zero by zero. Give it a temporary background colour and look.
- Is a parent claiming the touch? Add
.onBegin(() => console.log('begin'))— if begin fires but change never does, another recogniser won the race. - Is the child animatable and singular?
GestureDetectorwants exactly one child, and style updates needAnimated.View. - Are the versions matched?
npx expo installresolves both libraries against your SDK. A Reanimated installed with plainnpm installis a reliable source of worklet errors that look like gesture errors.
Skip the wiring
Root view, matched versions, swipe rows with sane thresholds — this is setup work that is identical in every project and interesting in none of them. Describe your app at shipnative.dev and you get a React Native app with Gesture Handler and Reanimated already installed and mounted correctly, running on your phone in minutes, with the full Expo project available to export and edit. See also the performance fixes for what else keeps a list at 60fps once the gestures are right.