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.
Table of Contents
- 1. Why react-native-gesture-handler exists
- 2. The declarative Gesture API at a glance
- 3. GestureDetector: attaching gestures to native views
- 4. Composing gestures: Race, Simultaneous and Exclusive
- 5. Hands-on: swipe to delete a list item
- 6. Hands-on: a draggable card with spring-back
- 7. Hands-on: pinch to zoom in an image viewer
- 8. Resolving gesture conflicts with ScrollView and FlatList
- 9. PanResponder vs. Gesture Handler compared
- 10. Summary
- 11. FAQ
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.