Shipnative
ShipnativeShipnative
Sign in
GuideAugust 2026 · 11 min read

React Native Gesture Handler: Pan, Swipe, and Tap

Gesture Handler is one of two libraries almost every non-trivial React Native app ends up depending on, and it is the one people install, copy an example for, and never really learn. Which is fine right up until a drag fights with a list, a tap stops firing on Android, or a swipe that looked smooth in the simulator stutters on a mid-range phone. This is the current Gesture API — the builder-style one, not the deprecated handler components — with the failure modes that cost the most time.

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:

  • translationX is cumulative for the gesture, not a delta. It measures from the touch-down point, which is why you snapshot the starting offset in onBegin and add. Adding translationX to the current value on every frame double-counts and sends the card flying.
  • onFinalize, not onEnd, for cleanup. onEnd is 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 in onFinalize, which always runs.
  • The child must be animatable. GestureDetector attaches to whatever single child you give it, and that child needs to be an Animated.View for 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:

APIBehaviourTypical use
Gesture.Simultaneous(a, b)Both recognise at oncePinch plus rotate on a photo viewer
Gesture.Race(a, b)First to activate wins, the other is cancelledPan or long-press on the same card
Gesture.Exclusive(a, b)Tries in order, falls through on failureDouble tap first, single tap as fallback
.requireExternalGestureToFail(other)Waits for another gesture to failTap 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 native Modal?
  • 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? GestureDetector wants exactly one child, and style updates need Animated.View.
  • Are the versions matched? npx expo install resolves both libraries against your SDK. A Reanimated installed with plain npm install is 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.

Frequently Asked Questions

Why does my gesture work on iOS but do nothing on Android?

Almost always a missing GestureHandlerRootView. On iOS the library patches the root view automatically in most setups, while on Android gestures outside a GestureHandlerRootView are simply never delivered — no error, no warning. Wrap your app root (or the root layout in Expo Router) in <GestureHandlerRootView style={{ flex: 1 }}> and the same code starts working.

Should I still use PanResponder?

No, not for new code. PanResponder runs the gesture on the JavaScript thread, so every drag competes with your renders and stutters the moment the thread is busy. Gesture Handler recognises gestures on the native side and, paired with Reanimated worklets, runs your handler on the UI thread — which is why one stays smooth during a list re-render and the other does not.

What is the difference between the Gesture API and useAnimatedGestureHandler?

They are two generations of the same idea. useAnimatedGestureHandler with PanGestureHandler components is the version 1 style and is deprecated. The current API is Gesture.Pan() built with a builder chain and attached through a single GestureDetector, and it composes gestures with Gesture.Simultaneous, Race, and Exclusive instead of ref juggling.

How do I make a gesture work inside a ScrollView or FlatList?

Decide which one wins on each axis. A horizontal pan inside a vertical scroll should call .activeOffsetX([-10, 10]) and .failOffsetY([-8, 8]) so it only claims the gesture after clear horizontal movement and gives up as soon as the finger moves vertically. Without those thresholds both recognisers fight for the same touch and the list feels sticky.

Do I need Reanimated to use Gesture Handler?

Not strictly — a gesture callback can call plain JavaScript with runOnJS. But without Reanimated the visual update goes back through the JS thread and you lose the main reason to use the library. In practice they ship together, and Expo installs compatible versions of both.

Does it work in Expo Go?

Yes. react-native-gesture-handler and react-native-reanimated are both bundled into Expo Go, so no development build is needed for either. Install them with npx expo install so the versions match your SDK.

→

React Native Animations

Reanimated shared values and the UI thread these gestures write to.

Read guide →
→

React Native Bottom Sheet

The most common real use of a pan gesture, already assembled.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.