Gesture Handler in React Native: Swipe, Drag and Pinch Gestures
AI generated
RN
native
React Native · Gesture Handler · Reanimated · Mobile UX
Gesture Handler in React Native
Master Swipe, Drag and Pinch Gestures

Building touch interactions in React Native on the old PanResponder API gives up responsiveness and risks dropped touch events under load. The Gesture Handler recognizes swipe, drag and pinch gestures natively, before the JavaScript thread is even involved, and forms the foundation for swipeable product lists, draggable cards and pinch-to-zoom image viewers in mobile commerce apps.

18 min read Gesture.Pan · Gesture.Pinch · GestureDetector · Reanimated react-native-gesture-handler 2.x

1. Why react-native-gesture-handler exists

The built in PanResponder API in React Native was the default way to recognize touch gestures for a long time, and it works around a simple idea. Every touch event travels over the bridge from the native layer to the JavaScript thread, gets evaluated there, and the decision to activate or reject the gesture is sent back. That is exactly the problem. The moment the JS thread is busy with rendering, data processing or network logic, evaluating touch events gets delayed, and a swipe gesture feels choppy instead of smooth. In a product list with hundreds of items, or during a network request, this effect becomes noticeable fast, and that is exactly where the Gesture Handler steps in.

react-native-gesture-handler moves gesture recognition entirely into the native layer: on iOS through UIGestureRecognizer, on Android through an equivalent native recognition system. Touch events are no longer shipped one by one across the bridge, they get evaluated natively, while only the result, say a position change or a completion state, is reported back to the JavaScript side. A Gesture Handler does not block when the JS thread happens to be busy, and it does not drop touch events under load either, because the actual recognition was never dependent on the state of the JS thread in the first place.

For mobile commerce apps, this difference is not an academic detail. Swipeable product galleries, drag to reorder in the cart, or a pinch-to-zoom viewer for product photos are interactions where users notice any delay immediately as poor app quality. A Gesture Handler that delivers native speed is therefore one of the few investments that translates directly into perceived performance, regardless of how complex the rest of the app logic runs in the background.


#!/usr/bin/env bash
# Install react-native-gesture-handler and the required native modules

npm install react-native-gesture-handler react-native-reanimated

# iOS: install pods for the native gesture recognizers
cd ios && pod install && cd ..

# Android: autolinking handles the native module (RN 0.60+),
# no manual MainActivity changes required in most setups

# babel.config.js needs the Reanimated plugin, always listed last:
#   plugins: ['react-native-reanimated/plugin']

# Wrap the app root once in App.tsx:
#   import { GestureHandlerRootView } from 'react-native-gesture-handler';
#   export default function App() {
#     return (
#       <GestureHandlerRootView style={{ flex: 1 }}>
#         <RootNavigator />
#       </GestureHandlerRootView>
#     );
#   }

2. The declarative Gesture API at a glance

Since version 2, the Gesture Handler ships a declarative Gesture API that replaces the older, component based API where PanGestureHandler and TapGestureHandler were standalone components. Instead of declaring a gesture as a JSX component, it is built as a configuration object: Gesture.Pan(), Gesture.Pinch(), Gesture.Tap() and Gesture.LongPress() each return a gesture builder, to which options and callbacks are attached through method chaining. The result is noticeably more readable code, because configuration and behavior of a gesture live in one place instead of being spread across props of a component.

Every gesture in the Gesture Handler shares the same basic callbacks: onBegin fires as soon as the gesture is recognized but before it becomes active, onStart marks the transition into the active state, onUpdate delivers continuous values for continuous gestures like Pan or Pinch, and onEnd or onFinalize close out the gesture. For Gesture.Pan(), every update event carries fields such as translationX, translationY and velocityX, for Gesture.Pinch() a continuous scale factor relative to the start of the gesture. Gesture.Tap() and Gesture.LongPress() are discrete gestures that fire once, but can be fine tuned through numberOfTaps() or minDuration().

A key advantage of the new Gesture API is that these callbacks connect directly to Reanimated, as long as they run with the worklet modifier. Values like translationX can be written straight into a shared value without a detour through the JavaScript thread, which in turn drives an animation. The Gesture Handler owns recognition, Reanimated owns animation, and both run on the UI thread, without a single frame ever needing to cross over to the slower JS thread.


import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';

function GestureBasicsDemo() {
  const scale = useSharedValue(1);
  const pressed = useSharedValue(false);

  // Tap gesture: fires once per finger-down and finger-up cycle
  const tap = Gesture.Tap()
    .maxDuration(250)
    .onStart(() => {
      pressed.value = true;
    })
    .onEnd(() => {
      pressed.value = false;
    });

  // Long press gesture: fires after a minimum hold duration
  const longPress = Gesture.LongPress()
    .minDuration(500)
    .onStart(() => {
      scale.value = withSpring(1.1);
    });

  // Pinch gesture: reports a continuous scale factor per update
  const pinch = Gesture.Pinch()
    .onUpdate((event) => {
      scale.value = event.scale;
    })
    .onEnd(() => {
      scale.value = withSpring(1);
    });

  // Race: only the gesture that activates first wins, others are cancelled
  const tapOrLongPress = Gesture.Race(tap, longPress);

  // Simultaneous: both branches can stay active at the same time
  const composed = Gesture.Simultaneous(tapOrLongPress, pinch);

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));

  return (
    <GestureDetector gesture={composed}>
      <Animated.View style={[styles.box, animatedStyle]} />
    </GestureDetector>
  );
}

3. GestureDetector: attaching gestures to native views

GestureDetector is the only component the new Gesture API needs to attach one or more gestures to a view. Unlike the old API, where every handler type was its own wrapper component, GestureDetector accepts a single gesture prop and renders exactly one direct child. That child has to be a native view that accepts a ref, typically Animated.View from Reanimated when the gesture should drive an animation, or a plain View for purely logical gestures without visual feedback through transform.

A common beginner mistake is wrapping GestureDetector around a component that does not itself render a native view element, for example a plain function component without forwardRef. The Gesture Handler then cannot find a native handle to attach the gesture to, and the gesture stays inert. The fix is either to target native primitives directly, such as View, Image or Animated.View, or to wrap a custom component properly with React.forwardRef so the underlying native handle gets passed through.

Because GestureDetector itself does not need extra bridging communication per frame, the overhead of nesting multiple detectors stays low. In practice this makes it possible to build a card stack where each card carries its own GestureDetector with its own pan gesture, while the parent list scrolls independently, as long as activation ranges and priorities are handled cleanly through simultaneousHandlers and the composition APIs covered next.

4. Composing gestures: Race, Simultaneous and Exclusive

Once more than one gesture is attached to the same view or to nested views, the Gesture Handler needs to know how those gestures should relate to each other. The Gesture API offers three composition functions for that. Gesture.Race(...) pits multiple gestures against each other: the moment one of them activates, all the others are cancelled immediately. That is the right choice when tap and long press live on the same surface and should exclude one another.

Gesture.Simultaneous(...), on the other hand, allows multiple gestures to stay active in parallel, for example when a pinch gesture for zooming should run alongside a pan gesture that moves the zoomed viewport. Both gestures get their own callbacks and update independent shared values without blocking each other. Gesture.Exclusive(...) finally defines a priority order: the gesture passed first gets priority, and only if it fails or never activates does the next gesture in the list get a chance. A double tap ahead of a single pinch gesture is a typical example where Exclusive prevents a double tap from accidentally being interpreted as two separate tap gestures.

These three composition functions are not a cosmetic add-on, they are the actual core of what separates the Gesture Handler from the old, imperative handler chaining. Instead of manually checking which handler reacted first and syncing state between multiple PanResponder instances, Race, Simultaneous and Exclusive let you describe declaratively how gestures should relate to each other, and leave the actual conflict resolution to the native recognition system.

5. Hands-on: swipe to delete a list item

Swipe to delete is one of the most common gesture patterns in mobile shopping apps, for example to remove an item from the cart or wishlist. The basic idea: a Gesture.Pan() recognizes a horizontal swipe, a shared value translateX follows the finger, and on release either the distance traveled or the velocity of the gesture decides whether the item gets deleted or slides back to its original position. The activeOffsetX option matters here, since it tells the Gesture Handler at which horizontal movement the pan gesture should even activate, so vertical scrolling in the list is not accidentally interpreted as a swipe.

The actual delete requires runOnJS, because the callback in onEnd runs as a worklet on the UI thread, while removing the item from state has to happen on the JavaScript thread. This separation is typical for the Gesture Handler combined with Reanimated: the gesture itself, its continuous updates and even the completion animation run entirely natively, only the actual data state gets reported back once, across the bridge, at the very end.


import React from 'react';
import { StyleSheet } from 'react-native';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  runOnJS,
} from 'react-native-reanimated';

const DELETE_THRESHOLD = -120;

function SwipeableListItem({ item, onDelete }) {
  const translateX = useSharedValue(0);

  const pan = Gesture.Pan()
    .activeOffsetX([-10, 10]) // ignore near-vertical drags, let the list scroll instead
    .onUpdate((event) => {
      // Only allow swiping to the left
      translateX.value = Math.min(0, event.translationX);
    })
    .onEnd((event) => {
      const shouldDelete = translateX.value < DELETE_THRESHOLD || event.velocityX < -800;
      if (shouldDelete) {
        translateX.value = withTiming(-500, { duration: 200 }, () => {
          runOnJS(onDelete)(item.id);
        });
      } else {
        translateX.value = withTiming(0);
      }
    });

  const rowStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }],
  }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={[styles.row, rowStyle]}>
        <Animated.Text style={styles.title}>{item.title}</Animated.Text>
      </Animated.View>
    </GestureDetector>
  );
}

6. Hands-on: a draggable card with spring-back

A freely draggable card that snaps back with a spring animation on release is a second classic Gesture Handler pattern, for example for drag to reorder lists in the cart or for playful onboarding interactions. The challenge is that a pan gesture, by definition, always reports movement values relative to the start of that particular gesture, resetting to zero every time a new gesture begins. Without extra state, the card would jump back to its original position each time it gets grabbed again, instead of continuing from its current position.

The fix is two additional shared values, startX and startY, that cache the current value of translateX and translateY inside the onStart callback. Every subsequent onUpdate adds the relative movement of the gesture on top of this cached start value instead of overwriting it. On release, withSpring from Reanimated takes over the return to the origin with a natural feeling spring curve, whose damping and stiffness parameters directly control how fast and how strongly the card settles.


import React from 'react';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from 'react-native-reanimated';

function DraggableProductCard() {
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const startX = useSharedValue(0);
  const startY = useSharedValue(0);

  const pan = Gesture.Pan()
    .onStart(() => {
      // Remember the current offset, avoids a jump when re-grabbing the card
      startX.value = translateX.value;
      startY.value = translateY.value;
    })
    .onUpdate((event) => {
      translateX.value = startX.value + event.translationX;
      translateY.value = startY.value + event.translationY;
    })
    .onEnd(() => {
      // Snap back to the origin with a natural spring curve
      translateX.value = withSpring(0, { damping: 14, stiffness: 140 });
      translateY.value = withSpring(0, { damping: 14, stiffness: 140 });
    });

  const cardStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: translateX.value },
      { translateY: translateY.value },
    ],
  }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={[styles.card, cardStyle]} />
    </GestureDetector>
  );
}

7. Hands-on: pinch to zoom in an image viewer

For product images in an online shop, pinch to zoom is one of the most expected gestures of all, because users know this exact behavior from native photo apps. Gesture.Pinch() reports a relative scale factor in every onUpdate callback, relative to the state at the start of the gesture, not an absolute zoom level. That is why this gesture also needs an extra shared value, savedScale, which holds the last reached zoom factor across multiple pinch gestures, so a second pinch continues seamlessly from the previous zoom state instead of starting over at one.

A production ready pinch-to-zoom handler also needs limits: without bounds, an image could shrink or grow indefinitely, hurting texture quality and layout. Math.min and Math.max around the computed scale value keep the zoom within sensible bounds, while an additional Gesture.Tap() with numberOfTaps(2) acts as a double tap gesture that resets the zoom. Through Gesture.Exclusive(), the double tap gets priority over the pinch gesture, so a quick double tap does not get misread as the beginning of a pinch movement.


import React from 'react';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
} from 'react-native-reanimated';

function PinchZoomImage({ source }) {
  const scale = useSharedValue(1);
  const savedScale = useSharedValue(1);

  const pinch = Gesture.Pinch()
    .onUpdate((event) => {
      // Clamp so the image cannot shrink below original size or zoom too far
      scale.value = Math.min(Math.max(savedScale.value * event.scale, 1), 4);
    })
    .onEnd(() => {
      savedScale.value = scale.value;
      if (scale.value < 1.05) {
        scale.value = withTiming(1);
        savedScale.value = 1;
      }
    });

  // Double tap resets the zoom, a common companion gesture for image viewers
  const doubleTap = Gesture.Tap()
    .numberOfTaps(2)
    .onStart(() => {
      scale.value = withTiming(1);
      savedScale.value = 1;
    });

  // Exclusive: the double tap gets priority over the pinch gesture
  const composed = Gesture.Exclusive(doubleTap, pinch);

  const imageStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));

  return (
    <GestureDetector gesture={composed}>
      <Animated.Image source={source} style={[styles.image, imageStyle]} />
    </GestureDetector>
  );
}

8. Resolving gesture conflicts with ScrollView and FlatList

The moment a Gesture Handler runs inside a scrollable list, a competing situation arises inevitably: a horizontal swipe should move the list item, while a vertical swipe should scroll the list itself, and both movements start with a very similar initial touch. ScrollView and FlatList already ship their own native pan handler internally for scrolling, and without explicit configuration, that internal handler competes directly with any custom Gesture Handler defined inside the list items.

The first line of defense is narrowing the activation threshold of every custom pan gesture through activeOffsetX and failOffsetY, as already shown in the swipe-to-delete example. A pan gesture that only activates on predominantly horizontal movement and fails immediately on predominantly vertical movement leaves any movement that is clearly a scroll intent to the list. If that is not enough, for example because two independent Gesture Handlers should evaluate the same touch sequence at the same time, simultaneousHandlers comes into play: it explicitly tells a Gesture Handler which other handler, say the internal scroll handler of a ScrollView ref, it is allowed to stay active alongside, instead of blocking each other.

In practice, the combination of both techniques works best: activation thresholds as a first, cheap filtering stage right inside the native recognition system, and simultaneousHandlers or the composition APIs from section four as a targeted exception for the cases where two gestures should genuinely be evaluated at the same time. Anyone who follows this order avoids the most common cause of janky or completely blocked lists in production apps with many nested Gesture Handlers.

9. PanResponder vs. Gesture Handler compared

The decision between the old PanResponder API and the Gesture Handler has already been made in most new React Native projects today, but a direct comparison shows why. The table below lays out the main dimensions where the two approaches differ in practice.

Dimension PanResponder react-native-gesture-handler Advantage
Recognition thread JavaScript thread over the bridge Native UI thread No bridge latency per touch event
Responsiveness under load Dropped or delayed events when JS is blocked Stays smooth regardless of the JS thread Consistent responsiveness
Combining gestures Manual state logic across handler instances Gesture.Race/Simultaneous/Exclusive Declarative, readable composition
ScrollView conflicts Often blocking, hard to debug activeOffsetX + simultaneousHandlers Targeted, configurable priority
Learning curve Familiar, but a lot of boilerplate New API, but noticeably less code Faster ramp up with Reanimated

Taken together, the comparison shows that the Gesture Handler is not just a faster solution, it is a structurally different one. Where PanResponder treats gesture recognition as a byproduct of the JS thread, the Gesture Handler makes native touch recognition the actual foundation, and the declarative Gesture API additionally ensures that complex gesture compositions no longer end up as nested, hard to maintain state logic.

Mironsoft

Touch-friendly React Native apps for Magento-based mobile commerce

Gestures that feel native, not just look native?

We build React Native storefronts for Magento shops with swipeable product galleries, drag to reorder in the cart, and pinch-to-zoom image viewers, implemented with react-native-gesture-handler and Reanimated for noticeably smooth interactions.

UX Consulting

Analysis of existing touch interactions and prioritization of the gestures with the biggest UX impact

Gesture Implementation

Swipe to delete, draggable cards and pinch to zoom with react-native-gesture-handler and Reanimated

Performance Tuning

Resolving gesture conflicts with lists and ScrollViews, connected to Magento cart and catalog

10. Summary

The Gesture Handler solves a very concrete problem of the old PanResponder API: touch recognition that depends on the JavaScript thread becomes unreliable under load and never feels as smooth as native interactions. By moving the recognition of swipe, drag and pinch gestures entirely into the native layer, react-native-gesture-handler keeps responsiveness stable even when the JS thread is busy with other work. The declarative Gesture API with Gesture.Pan(), Gesture.Pinch(), Gesture.Tap() and Gesture.LongPress() makes individual gestures cleanly configurable, while Gesture.Race, Gesture.Simultaneous and Gesture.Exclusive keep even complex combinations of multiple gestures manageable.

In practice, the strengths of the Gesture Handler show most clearly in concrete patterns such as swipe to delete, a freely draggable card with spring-back, or a pinch-to-zoom image viewer, all three built on the same underlying principle: GestureDetector attaches a gesture to a native view, shared values from Reanimated hold state between frames, and only the final data state gets reported back to the JavaScript thread via runOnJS when needed. Anyone who additionally applies activation thresholds like activeOffsetX and tools like simultaneousHandlers deliberately avoids the most common conflicts with ScrollView and FlatList, and ships gesture interactions that feel native in mobile commerce apps.

Gesture Handler in React Native: Key Takeaways

Native instead of the JS thread

The Gesture Handler recognizes gestures natively through UIGestureRecognizer and Android equivalents, independent of how busy the JS thread is.

Declarative Gesture API

Gesture.Pan(), Gesture.Pinch(), Gesture.Tap() and Gesture.LongPress() replace the old handler components with method chaining.

Composition via Race/Simultaneous/Exclusive

Multiple gestures combine declaratively instead of syncing manual state logic across handlers.

Resolving conflicts deliberately

activeOffsetX and simultaneousHandlers prevent deadlocks between custom gestures and ScrollView/FlatList.

11. FAQ: Gesture Handler in React Native

1What is react-native-gesture-handler?
A library that recognizes swipe, drag, pinch, tap and long press natively on iOS and Android instead of on the JS thread. Its central component is GestureDetector with the declarative Gesture API.
2Why is PanResponder not enough anymore?
PanResponder evaluates touch events on the JS thread and drops or delays them under load. The Gesture Handler recognizes gestures natively and stays independent of that.
3Gesture.Pan() vs. PanResponder?
Gesture.Pan() offers callbacks as worklets that work directly with Reanimated shared values. PanResponder requires manual state management entirely on the JS thread.
4How do I combine multiple gestures?
Gesture.Race, Gesture.Simultaneous and Gesture.Exclusive let gestures compete, stay active in parallel, or get prioritized declaratively.
5What does GestureDetector do?
Attaches a gesture object to exactly one native child view, whether View, Image or Animated.View, as long as it accepts a native ref.
6Avoiding conflicts with ScrollView?
activeOffsetX and failOffsetY narrow the activation direction, simultaneousHandlers additionally allows true simultaneity with the internal scroll handler.
7Do I need Reanimated as well?
Not strictly, but in practice almost always, since Reanimated supplies the shared values that Gesture Handler callbacks use to drive performant animations directly.
8Building swipe to delete in FlatList?
Each row gets its own GestureDetector with Gesture.Pan(), removal from state runs through runOnJS in the onEnd callback after evaluating swipe distance or velocity.
9How does pinch to zoom work?
Gesture.Pinch() reports a relative scale factor, an additional shared value stores the last zoom state, bounds are enforced via Math.min and Math.max.
10Compatible with the New Architecture?
Yes, current versions support both the old bridge and Fabric with TurboModules, without any changes needed to the Gesture API in your code.