React Native Gesture Handler: Swipe-to-Delete, Drag Handles, and Pinch-to-Zoom with Reanimated
Master React Native Gesture Handler v2 with Reanimated 3. Build swipe-to-delete list items, draggable reorder handles, and pinch-to-zoom image viewers with production-ready TypeScript code.
Touch interactions are where React Native apps either feel native or feel wrong. A swipe-to-delete that stutters, a drag-and-drop that lags behind the finger, or a pinch-to-zoom that jumps โ these moments destroy the perception of quality. React Native Gesture Handler v2 combined with Reanimated 3 runs all animation logic on the UI thread, eliminating the JS bridge bottleneck that caused these problems.
This guide covers the three gestures you'll implement in almost every production app: swipe-to-delete for list items, drag handles for reordering, and pinch-to-zoom for image viewing.
Installation and Setup
npx expo install react-native-gesture-handler react-native-reanimated
For bare React Native projects:
npm install react-native-gesture-handler react-native-reanimated
npx pod-install
Configure Reanimated in Babel:
// babel.config.js
module.exports = {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
Wrap your app root with the GestureHandlerRootView:
// app/_layout.tsx (Expo Router)
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
{/* your navigation */}
</GestureHandlerRootView>
);
}
Core Concepts: Worklets and Shared Values
Reanimated 3 runs animation code on the UI thread via worklets โ functions marked with 'worklet' directive or created via hooks like useAnimatedStyle. Shared values bridge JS and UI thread state:
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
// Shared value lives on UI thread, readable from both JS and worklets
const translateX = useSharedValue(0);
// Animated style runs on UI thread โ no JS bridge
const animStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
// Gesture runs on UI thread
const pan = Gesture.Pan()
.onUpdate((e) => {
translateX.value = e.translationX; // direct UI thread assignment
})
.onEnd(() => {
translateX.value = withSpring(0); // spring back
});
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Swipe-to-Delete
The classic list item gesture: swipe left to reveal a delete button, swipe further to trigger deletion.
// components/SwipeableRow.tsx
import React, { useCallback } from 'react';
import { StyleSheet, View, Text, Pressable, Alert } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withSpring,
runOnJS,
interpolate,
Extrapolation,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Trash2 } from 'lucide-react-native';
const DELETE_THRESHOLD = -80;
const SNAP_OPEN = -80;
const DELETE_TRIGGER = -200;
interface SwipeableRowProps {
item: { id: string; title: string; subtitle: string };
onDelete: (id: string) => void;
children?: React.ReactNode;
}
export function SwipeableRow({ item, onDelete, children }: SwipeableRowProps) {
const translateX = useSharedValue(0);
const isOpen = useSharedValue(false);
const triggerDelete = useCallback(() => {
Alert.alert('Delete Item', 'Are you sure?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => onDelete(item.id),
},
]);
}, [item.id, onDelete]);
const pan = Gesture.Pan()
.activeOffsetX([-10, 10]) // Prevent conflict with ScrollView
.failOffsetY([-5, 5])
.onUpdate((e) => {
if (isOpen.value) {
// Already open: allow closing (right swipe) or triggering delete
translateX.value = Math.min(SNAP_OPEN + e.translationX, 0);
} else {
// Closed: only allow left swipe
translateX.value = Math.min(e.translationX, 0);
}
})
.onEnd((e) => {
const current = translateX.value;
if (current < DELETE_TRIGGER) {
// Swiped past delete trigger โ animate out and delete
translateX.value = withTiming(-400, { duration: 200 }, () => {
runOnJS(onDelete)(item.id);
});
return;
}
if (current < DELETE_THRESHOLD) {
// Snap to open position
translateX.value = withSpring(SNAP_OPEN, { damping: 20, stiffness: 200 });
isOpen.value = true;
} else {
// Snap back to closed
translateX.value = withSpring(0, { damping: 20, stiffness: 200 });
isOpen.value = false;
}
});
const tap = Gesture.Tap().onEnd(() => {
if (isOpen.value) {
translateX.value = withSpring(0, { damping: 20, stiffness: 200 });
isOpen.value = false;
}
});
const composed = Gesture.Simultaneous(pan, tap);
const rowStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const deleteActionStyle = useAnimatedStyle(() => {
const opacity = interpolate(
translateX.value,
[-200, -80, 0],
[1, 1, 0],
Extrapolation.CLAMP
);
const scale = interpolate(
translateX.value,
[-200, -80],
[1.2, 1],
Extrapolation.CLAMP
);
return { opacity, transform: [{ scale }] };
});
return (
<View style={styles.container}>
{/* Delete action (revealed behind) */}
<Animated.View style={[styles.deleteAction, deleteActionStyle]}>
<Pressable
onPress={() => runOnJS(triggerDelete)()}
style={styles.deleteButton}
>
<Trash2 size={20} color="white" />
<Text style={styles.deleteText}>Delete</Text>
</Pressable>
</Animated.View>
{/* Row content */}
<GestureDetector gesture={composed}>
<Animated.View style={[styles.row, rowStyle]}>
{children ?? (
<View style={styles.content}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.subtitle}>{item.subtitle}</Text>
</View>
)}
</Animated.View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'relative',
backgroundColor: '#ef4444',
marginHorizontal: 16,
marginVertical: 4,
borderRadius: 12,
overflow: 'hidden',
},
deleteAction: {
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: 80,
justifyContent: 'center',
alignItems: 'center',
},
deleteButton: {
alignItems: 'center',
gap: 4,
paddingHorizontal: 12,
paddingVertical: 8,
},
deleteText: { color: 'white', fontSize: 12, fontWeight: '600' },
row: {
backgroundColor: 'white',
borderRadius: 12,
padding: 16,
},
content: { gap: 4 },
title: { fontSize: 16, fontWeight: '600', color: '#111827' },
subtitle: { fontSize: 14, color: '#6b7280' },
});
Use it in a FlatList:
// screens/ContactsScreen.tsx
import { FlatList, View } from 'react-native';
import { SwipeableRow } from '@/components/SwipeableRow';
export function ContactsScreen() {
const [contacts, setContacts] = useState(initialContacts);
const handleDelete = useCallback((id: string) => {
setContacts((prev) => prev.filter((c) => c.id !== id));
// API call
deleteContact(id).catch(() => {
// Restore on failure
setContacts((prev) => [...prev, contacts.find((c) => c.id === id)!]);
});
}, [contacts]);
return (
<FlatList
data={contacts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<SwipeableRow item={item} onDelete={handleDelete} />
)}
ItemSeparatorComponent={() => <View style={{ height: 0 }} />}
/>
);
}
Drag-and-Drop Reordering
Building a draggable list requires tracking each item's position and allowing reordering on drop.
// components/DraggableList.tsx
import React, { useState, useCallback } from 'react';
import { StyleSheet, View, Text } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
scrollTo,
useAnimatedRef,
useScrollViewOffset,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { GripVertical } from 'lucide-react-native';
const ITEM_HEIGHT = 64;
interface DraggableItem {
id: string;
label: string;
}
interface DraggableListProps {
items: DraggableItem[];
onReorder: (newOrder: DraggableItem[]) => void;
}
function DraggableRow({
item,
index,
positions,
isDragging,
activeIndex,
onDragStart,
onDragEnd,
}: {
item: DraggableItem;
index: number;
positions: Animated.SharedValue<number[]>;
isDragging: Animated.SharedValue<boolean>;
activeIndex: Animated.SharedValue<number>;
onDragStart: (index: number) => void;
onDragEnd: (from: number, to: number) => void;
}) {
const translateY = useSharedValue(0);
const scale = useSharedValue(1);
const zIndex = useSharedValue(0);
const shadowOpacity = useSharedValue(0);
const isActive = useSharedValue(false);
const pan = Gesture.Pan()
.onBegin(() => {
isActive.value = true;
scale.value = withSpring(1.05);
shadowOpacity.value = withTiming(0.3);
zIndex.value = 10;
activeIndex.value = index;
isDragging.value = true;
runOnJS(onDragStart)(index);
})
.onUpdate((e) => {
translateY.value = e.translationY;
// Calculate target position
const currentPos = index * ITEM_HEIGHT + e.translationY;
const targetIndex = Math.round(currentPos / ITEM_HEIGHT);
const clampedTarget = Math.max(
0,
Math.min(targetIndex, positions.value.length - 1)
);
if (clampedTarget !== activeIndex.value) {
activeIndex.value = clampedTarget;
}
})
.onEnd(() => {
const toIndex = activeIndex.value;
scale.value = withSpring(1);
shadowOpacity.value = withTiming(0);
zIndex.value = 0;
isActive.value = false;
isDragging.value = false;
const finalY = (toIndex - index) * ITEM_HEIGHT;
translateY.value = withSpring(finalY, { damping: 20 }, () => {
translateY.value = 0;
runOnJS(onDragEnd)(index, toIndex);
});
});
const rowStyle = useAnimatedStyle(() => ({
transform: [
{ translateY: translateY.value },
{ scale: scale.value },
],
zIndex: zIndex.value,
shadowOpacity: shadowOpacity.value,
elevation: isActive.value ? 5 : 0,
}));
return (
<Animated.View style={[styles.draggableRow, rowStyle]}>
<GestureDetector gesture={pan}>
<View style={styles.dragHandle}>
<GripVertical size={20} color="#9ca3af" />
</View>
</GestureDetector>
<Text style={styles.rowLabel}>{item.label}</Text>
</Animated.View>
);
}
export function DraggableList({ items: initialItems, onReorder }: DraggableListProps) {
const [items, setItems] = useState(initialItems);
const positions = useSharedValue(items.map((_, i) => i));
const isDragging = useSharedValue(false);
const activeIndex = useSharedValue(-1);
const handleDragStart = useCallback((_index: number) => {}, []);
const handleDragEnd = useCallback(
(fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return;
setItems((prev) => {
const newItems = [...prev];
const [moved] = newItems.splice(fromIndex, 1);
newItems.splice(toIndex, 0, moved);
onReorder(newItems);
return newItems;
});
},
[onReorder]
);
return (
<View style={styles.list}>
{items.map((item, index) => (
<DraggableRow
key={item.id}
item={item}
index={index}
positions={positions}
isDragging={isDragging}
activeIndex={activeIndex}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
list: { paddingVertical: 8 },
draggableRow: {
height: ITEM_HEIGHT,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'white',
marginHorizontal: 16,
marginVertical: 2,
borderRadius: 10,
paddingRight: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowRadius: 4,
},
dragHandle: {
padding: 16,
justifyContent: 'center',
alignItems: 'center',
},
rowLabel: { flex: 1, fontSize: 16, color: '#111827' },
});
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Pinch-to-Zoom Image Viewer
// components/PinchableImage.tsx
import React from 'react';
import { StyleSheet, Image, ImageSourcePropType } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
clamp,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
interface PinchableImageProps {
source: ImageSourcePropType;
width: number;
height: number;
minScale?: number;
maxScale?: number;
}
export function PinchableImage({
source,
width,
height,
minScale = 1,
maxScale = 5,
}: PinchableImageProps) {
const scale = useSharedValue(1);
const savedScale = useSharedValue(1);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const savedTranslateX = useSharedValue(0);
const savedTranslateY = useSharedValue(0);
const focalX = useSharedValue(0);
const focalY = useSharedValue(0);
const pinch = Gesture.Pinch()
.onBegin((e) => {
focalX.value = e.focalX;
focalY.value = e.focalY;
})
.onUpdate((e) => {
const newScale = clamp(savedScale.value * e.scale, minScale, maxScale);
scale.value = newScale;
// Translate toward focal point during zoom
if (e.scale !== 1) {
const scaleRatio = newScale / savedScale.value;
translateX.value =
savedTranslateX.value +
(focalX.value - width / 2) * (1 - scaleRatio);
translateY.value =
savedTranslateY.value +
(focalY.value - height / 2) * (1 - scaleRatio);
}
})
.onEnd(() => {
savedScale.value = scale.value;
savedTranslateX.value = translateX.value;
savedTranslateY.value = translateY.value;
// Snap back if below minimum scale
if (scale.value < minScale) {
scale.value = withSpring(minScale);
translateX.value = withSpring(0);
translateY.value = withSpring(0);
savedScale.value = minScale;
savedTranslateX.value = 0;
savedTranslateY.value = 0;
}
});
const pan = Gesture.Pan()
.minPointers(2) // Only allow pan during pinch (two fingers)
.onUpdate((e) => {
if (scale.value <= 1) return; // Don't pan at 1x
const maxPanX = ((scale.value - 1) * width) / 2;
const maxPanY = ((scale.value - 1) * height) / 2;
translateX.value = clamp(
savedTranslateX.value + e.translationX,
-maxPanX,
maxPanX
);
translateY.value = clamp(
savedTranslateY.value + e.translationY,
-maxPanY,
maxPanY
);
})
.onEnd(() => {
savedTranslateX.value = translateX.value;
savedTranslateY.value = translateY.value;
});
// Double-tap to zoom in/out
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd((e) => {
if (scale.value > 1) {
// Reset to 1x
scale.value = withSpring(1);
translateX.value = withSpring(0);
translateY.value = withSpring(0);
savedScale.value = 1;
savedTranslateX.value = 0;
savedTranslateY.value = 0;
} else {
// Zoom to 2.5x at tap point
const targetScale = 2.5;
const newTranslateX = (e.x - width / 2) * (1 - targetScale);
const newTranslateY = (e.y - height / 2) * (1 - targetScale);
scale.value = withSpring(targetScale);
translateX.value = withSpring(newTranslateX);
translateY.value = withSpring(newTranslateY);
savedScale.value = targetScale;
savedTranslateX.value = newTranslateX;
savedTranslateY.value = newTranslateY;
}
});
const composed = Gesture.Simultaneous(
Gesture.Race(doubleTap, pinch),
pan
);
const animStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ scale: scale.value },
],
}));
return (
<GestureDetector gesture={composed}>
<Animated.View style={[{ width, height, overflow: 'hidden' }]}>
<Animated.Image
source={source}
style={[{ width, height }, animStyle]}
resizeMode="contain"
/>
</Animated.View>
</GestureDetector>
);
}
Full-Screen Image Gallery with Swipe Navigation
// components/ImageGallery.tsx
import React, { useState } from 'react';
import {
Modal,
View,
Pressable,
Text,
StyleSheet,
useWindowDimensions,
} from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { X } from 'lucide-react-native';
import { PinchableImage } from './PinchableImage';
interface GalleryImage {
uri: string;
caption?: string;
}
interface ImageGalleryProps {
images: GalleryImage[];
initialIndex?: number;
onClose: () => void;
}
export function ImageGallery({
images,
initialIndex = 0,
onClose,
}: ImageGalleryProps) {
const { width, height } = useWindowDimensions();
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const translateX = useSharedValue(-initialIndex * width);
const pan = Gesture.Pan()
.activeOffsetX([-20, 20])
.failOffsetY([-10, 10])
.onUpdate((e) => {
translateX.value = -currentIndex * width + e.translationX;
})
.onEnd((e) => {
const velocity = e.velocityX;
const translation = e.translationX;
let nextIndex = currentIndex;
if (translation < -width / 3 || velocity < -500) {
nextIndex = Math.min(currentIndex + 1, images.length - 1);
} else if (translation > width / 3 || velocity > 500) {
nextIndex = Math.max(currentIndex - 1, 0);
}
translateX.value = withSpring(-nextIndex * width, {
velocity: velocity,
damping: 30,
stiffness: 200,
});
runOnJS(setCurrentIndex)(nextIndex);
});
const containerStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<Modal transparent animationType="fade" statusBarTranslucent>
<View style={[styles.overlay, { width, height }]}>
<Pressable style={styles.closeButton} onPress={onClose}>
<X size={24} color="white" />
</Pressable>
<Text style={styles.counter}>
{currentIndex + 1} / {images.length}
</Text>
<GestureDetector gesture={pan}>
<Animated.View
style={[styles.strip, { width: width * images.length }, containerStyle]}
>
{images.map((img, i) => (
<View key={i} style={{ width, height, justifyContent: 'center' }}>
<PinchableImage
source={{ uri: img.uri }}
width={width}
height={height * 0.8}
maxScale={6}
/>
{img.caption && (
<Text style={styles.caption}>{img.caption}</Text>
)}
</View>
))}
</Animated.View>
</GestureDetector>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
backgroundColor: 'black',
position: 'relative',
overflow: 'hidden',
},
closeButton: {
position: 'absolute',
top: 56,
right: 20,
zIndex: 10,
padding: 8,
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 20,
},
counter: {
position: 'absolute',
top: 60,
left: 0,
right: 0,
textAlign: 'center',
color: 'white',
fontSize: 16,
zIndex: 10,
},
strip: {
flexDirection: 'row',
flex: 1,
},
caption: {
color: 'rgba(255,255,255,0.8)',
textAlign: 'center',
paddingHorizontal: 24,
paddingTop: 12,
fontSize: 14,
},
});
Gesture Composition and Conflict Resolution
When gestures can conflict (pan vs. scroll, pinch vs. swipe), use composition:
// Exclusive: only one gesture activates at a time
const exclusive = Gesture.Exclusive(tapGesture, panGesture);
// Simultaneous: both activate at the same time (pinch + pan)
const simultaneous = Gesture.Simultaneous(pinchGesture, panGesture);
// Race: first to activate wins, others fail
const race = Gesture.Race(doubleTapGesture, singleTapGesture);
// Sequence: second activates only after first ends
const sequence = Gesture.Sequence(longPress, pan);
// Prevent conflict with ScrollView
const pan = Gesture.Pan()
.activeOffsetX([-10, 10]) // horizontal threshold before activation
.failOffsetY([-5, 5]); // fails if vertical movement > 5px (lets ScrollView win)
Performance Tips
| Concern | Solution |
|---|---|
| Gesture runs on JS thread | Use useAnimatedStyle + withSpring/withTiming โ stays on UI thread |
runOnJS causes jank | Minimize JS calls from worklets; batch state updates |
| FlatList swipe conflict | Use activeOffsetX + failOffsetY to define gesture territory |
| Over-rendering on drag | Use useSharedValue instead of useState for animation-only state |
| Android back swipe conflict | Wrap navigation container; set gestureEnabled: false on screens with gestures |
| Gesture not firing | Ensure GestureHandlerRootView wraps the entire app, not just the screen |
Cost and Timeline Estimates
| Scope | Team Size | Timeline | Cost Range |
|---|---|---|---|
| Single swipe-to-delete gesture | 1 dev | 1โ2 days | $300โ600 |
| Swipe + drag reorder on one screen | 1 dev | 3โ5 days | $800โ1,500 |
| Full gesture library (swipe, drag, pinch, gallery) | 1โ2 devs | 1โ2 weeks | $2,500โ5,000 |
| Custom gesture-driven UI (maps, canvas, editor) | 2โ3 devs | 3โ5 weeks | $8,000โ18,000 |
See Also
- React Native Animations with Reanimated 3
- React Native Navigation with Expo Router
- React Native Camera and QR Scanning
- React Native Push Notifications Setup
- React DnD Kit Kanban Board
Working With Viprasol
Gesture interactions are the invisible work that separates a good app from a great one. Our mobile team builds React Native apps where gestures feel as fast and natural as their native counterparts โ because they run on the UI thread, not through the JavaScript bridge.
What we deliver:
- Swipe-to-delete, drag-to-reorder, pinch-to-zoom โ all gesture-handler native
- Custom gesture systems for unique product requirements
- Performance profiling to catch dropped frames before release
- Cross-platform gesture parity (iOS and Android)
Talk to our team about your React Native app โ
Or explore our full mobile and web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.