Gestures
react-native-gesture-handler recognises pan, tap and long press natively, composes them, and drives animations on the UI thread.
const pan = Gesture.Pan().onUpdate((e) => { x.value = e.translationX; }); <GestureDetector gesture={pan}>...</GestureDetector>Touch handling in React Native's own components goes through the JavaScript thread. react-native-gesture-handler recognises gestures in native code instead, so they stay responsive while JavaScript is busy and they cooperate with native scrolling. Expo SDK 57 pins version ~2.32.0: npx expo install react-native-gesture-handler.
Declaring a gesture
Build one with Gesture.Pan(), Gesture.Tap(), Gesture.LongPress(), Gesture.Pinch() or Gesture.Fling(). Chain configuration — .minDistance(), .activeOffsetX(), .failOffsetY(), .numberOfTaps(), .minDuration() — and callbacks: .onBegin(), .onStart(), .onUpdate(), .onEnd(), .onFinalize(). Pass the result to a GestureDetector wrapped around the view. A pan event carries translationX and translationY (cumulative for the whole gesture, not per frame), velocityX, absoluteX and numberOfPointers. This is not a web pointer event: nothing bubbles, and there is no preventDefault.
Driving an animation
The callbacks are worklets, so they can write Reanimated shared values directly on the UI thread and the finger and the view move in the same frame. Calling ordinary React code such as a state setter from inside one needs an explicit hop back to the JavaScript thread.
Composing
Gesture.Race(a, b) lets the first to activate cancel the rest. Gesture.Simultaneous(a, b) lets both run. Gesture.Exclusive(a, b) gives a priority over b. Across components there are .simultaneousWithExternalGesture(), .requireExternalGestureToFail() and .blocksExternalGesture(). Gesture Handler 3 replaces these builders with hooks such as usePanGesture, so hook-based code you find online targets a newer version than SDK 57 ships.
Example
import { StyleSheet, Text } from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
export default function DragCard() {
const offsetX = useSharedValue(0);
const offsetY = useSharedValue(0);
const startX = useSharedValue(0);
const startY = useSharedValue(0);
const pan = Gesture.Pan()
.minDistance(5)
.onBegin(() => {
// remember where the card was, so drags accumulate
startX.value = offsetX.value;
startY.value = offsetY.value;
})
.onUpdate((event) => {
// translationX is cumulative for this gesture, not a per-frame delta
offsetX.value = startX.value + event.translationX;
offsetY.value = startY.value + event.translationY;
})
.onEnd(() => {
offsetX.value = withSpring(0);
offsetY.value = withSpring(0);
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offsetX.value }, { translateY: offsetY.value }],
}));
return (
<GestureHandlerRootView style={styles.root}>
<GestureDetector gesture={pan}>
<Animated.View style={[styles.card, animatedStyle]}>
<Text style={styles.label}>Drag me</Text>
</Animated.View>
</GestureDetector>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, alignItems: 'center', justifyContent: 'center' },
card: { width: 160, height: 100, borderRadius: 16, backgroundColor: '#1976d2', alignItems: 'center', justifyContent: 'center' },
label: { color: '#ffffff', fontWeight: '600' },
});When to use it
- Drag a card around the screen and spring it back when released.
- Swipe a list row sideways to reveal delete without breaking vertical scrolling.
- Tell a tap apart from a long press on the same thumbnail.
- Pinch and drag a photo at the same time in a viewer.
More examples
Tap and long press on the same view
On screen: a quick tap makes the row pop slightly and settle; holding for 300ms lifts and dims it until the finger leaves. Two gestures want the same touches, so Gesture.Exclusive orders them - without it the tap can fire at the end of a long press. Everything here is written to shared values from worklets, so no React state changes and nothing re-renders while the finger is down.
import { StyleSheet, Text } from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
export default function ReorderHandle() {
const scale = useSharedValue(1);
const lifted = useSharedValue(0);
const longPress = Gesture.LongPress()
.minDuration(300)
.onStart(() => {
lifted.value = withTiming(1, { duration: 150 });
})
.onFinalize(() => {
lifted.value = withTiming(0, { duration: 150 });
});
const tap = Gesture.Tap()
.maxDuration(250)
.onEnd(() => {
scale.value = withSequence(
withSpring(1.1, { duration: 120, dampingRatio: 1 }),
withSpring(1),
);
});
// Exclusive: the long press has first refusal, the tap runs only if it fails
const gesture = Gesture.Exclusive(longPress, tap);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value * (1 + lifted.value * 0.06) }],
opacity: 1 - lifted.value * 0.2,
}));
return (
<GestureHandlerRootView style={styles.root}>
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.row, animatedStyle]}>
<Text>Hold to reorder, tap to open</Text>
</Animated.View>
</GestureDetector>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, justifyContent: 'center', padding: 16 },
row: { padding: 16, borderRadius: 12, backgroundColor: '#eceff1' },
});A swipe that does not fight the list it lives in
On screen: dragging a row left uncovers a Delete label and the row snaps open past 80 points or springs shut below it; dragging up or down scrolls the list as usual. activeOffsetX and failOffsetY are what make the two coexist - the pan declines ambiguous touches instead of stealing them. This is the standard shape for swipe actions, and the list is a FlatList because the data is unbounded.
import { FlatList, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
type Invoice = { id: string; label: string };
type SwipeRowProps = { label: string };
const invoices: Invoice[] = [
{ id: '1041', label: 'Invoice 1041' },
{ id: '1042', label: 'Invoice 1042' },
{ id: '1043', label: 'Invoice 1043' },
];
function SwipeRow({ label }: SwipeRowProps) {
const x = useSharedValue(0);
const pan = Gesture.Pan()
.activeOffsetX([-12, 12]) // activate only on clearly horizontal movement
.failOffsetY([-8, 8]) // give up on vertical drift, so the list scrolls
.onUpdate((event) => {
x.value = Math.min(0, event.translationX); // leftwards only
})
.onEnd(() => {
x.value = withSpring(x.value < -80 ? -96 : 0); // snap open or closed
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: x.value }],
}));
return (
<View style={styles.rowWrapper}>
<Text style={styles.delete}>Delete</Text>
<GestureDetector gesture={pan}>
<Animated.View style={[styles.row, animatedStyle]}>
<Text>{label}</Text>
</Animated.View>
</GestureDetector>
</View>
);
}
export default function Invoices() {
return (
<GestureHandlerRootView style={styles.root}>
<FlatList
data={invoices}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <SwipeRow label={item.label} />}
/>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
root: { flex: 1 },
rowWrapper: { justifyContent: 'center' },
row: { padding: 16, backgroundColor: '#ffffff' },
delete: { position: 'absolute', right: 16, color: '#c62828', fontWeight: '600' },
});Pinch and pan at the same time
On screen: two fingers scale the photo and either finger count moves it, with the scale springing back to at least natural size when the fingers lift. Gesture.Simultaneous is the difference between a photo viewer and a frustrating one - under Gesture.Race the pinch would cancel the pan the moment it activated. Transform order matters: translate before scale, or the movement is multiplied by the zoom.
import { StyleSheet } from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
export default function PhotoViewer() {
const scale = useSharedValue(1);
const savedScale = useSharedValue(1);
const x = useSharedValue(0);
const y = useSharedValue(0);
const startX = useSharedValue(0);
const startY = useSharedValue(0);
const pinch = Gesture.Pinch()
.onUpdate((event) => {
scale.value = savedScale.value * event.scale; // event.scale is relative
})
.onEnd(() => {
savedScale.value = Math.max(1, scale.value); // never smaller than natural size
scale.value = withSpring(savedScale.value);
});
const pan = Gesture.Pan()
.onBegin(() => {
startX.value = x.value;
startY.value = y.value;
})
.onUpdate((event) => {
x.value = startX.value + event.translationX;
y.value = startY.value + event.translationY;
});
// Simultaneous: pinching does not cancel the drag
const gesture = Gesture.Simultaneous(pinch, pan);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: x.value },
{ translateY: y.value },
{ scale: scale.value },
],
}));
return (
<GestureHandlerRootView style={styles.root}>
<GestureDetector gesture={gesture}>
<Animated.Image source={require('./photo.jpg')} style={[styles.photo, animatedStyle]} />
</GestureDetector>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#000000' },
photo: { width: 300, height: 300 },
});
Discussion