This guide walks you through building a swipe-based card UI in React Native using Reanimated 3 and React Native Gesture Handler 2. You will have a fully working, production-ready swipe stack by the end, and the build takes roughly 90 minutes from a blank Expo project.
What You'll Build
- A gesture-driven card stack that accepts left, right, and upward swipes
- Smooth spring-physics animations powered by Reanimated 3 shared values
- A rotation effect that mirrors the natural arc of a physical card flick
- Callback hooks so each swipe direction triggers a distinct business action
- A card-reset mechanism that snaps rejected cards back to the deck
Prerequisites
- Node 20+ and pnpm 9 (or npm 10)
- Expo SDK 52 or a bare React Native 0.76 project
- Basic familiarity with React hooks and TypeScript
- A physical device or a simulator with Hermes enabled
Step 1: Install the Required Packages
Why do you need both Reanimated and Gesture Handler?
React Native's built-in Animated API runs on the JS thread. That means every animation frame competes with your component renders. Reanimated 3 moves animation calculations to the UI thread using worklets, which cuts frame drops by roughly 60% on mid-range Android devices. Gesture Handler complements it by also running gesture recognition off the JS thread, so both systems talk directly to the native layer without a bridge round-trip.
pnpm add react-native-reanimated@3 react-native-gesture-handler@2
npx expo install react-native-reanimated react-native-gesture-handler
After installation, open babel.config.js and add the Reanimated plugin. It must be the last plugin in the array.
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
};
Clear your Metro cache after this change.
npx expo start --clear
Common pitfall: Forgetting --clear after modifying babel.config.js causes the Reanimated worklet transform to silently fail. Your animations will appear to work but will run on the JS thread instead, causing jank.
Step 2: Wrap Your App in GestureHandlerRootView
What happens if you skip this step?
Gesture Handler v2 requires a native root view. Without it, gestures silently fall back to React Native's touch system and your swipe logic will not fire on Android at all.
Open App.tsx and wrap your root component.
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StyleSheet } from 'react-native';
import CardStack from './components/CardStack';
export default function App() {
return (
);
}
const styles = StyleSheet.create({
root: { flex: 1 },
});
Step 3: Build the Swipeable Card Component
Create components/SwipeCard.tsx. This component holds all the gesture and animation logic for a single card.
import React from 'react';
import { StyleSheet, View, Text, Dimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
interpolate,
Extrapolation,
} from 'react-native-reanimated';
const SCREEN_WIDTH = Dimensions.get('window').width;
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.35;
type SwipeDirection = 'left' | 'right' | 'up';
interface SwipeCardProps {
label: string;
onSwipe: (direction: SwipeDirection) => void;
}
export default function SwipeCard({ label, onSwipe }: SwipeCardProps) {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const gesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
translateY.value = event.translationY;
})
.onEnd((event) => {
const swipedRight = event.translationX > SWIPE_THRESHOLD;
const swipedLeft = event.translationX < -SWIPE_THRESHOLD;
const swipedUp = event.translationY < -SWIPE_THRESHOLD;
if (swipedRight) {
translateX.value = withSpring(SCREEN_WIDTH * 1.5);
runOnJS(onSwipe)('right');
} else if (swipedLeft) {
translateX.value = withSpring(-SCREEN_WIDTH * 1.5);
runOnJS(onSwipe)('left');
} else if (swipedUp) {
translateY.value = withSpring(-800);
runOnJS(onSwipe)('up');
} else {
translateX.value = withSpring(0);
translateY.value = withSpring(0);
}
});
const animatedStyle = useAnimatedStyle(() => {
const rotate = interpolate(
translateX.value,
[-SCREEN_WIDTH, 0, SCREEN_WIDTH],
[-15, 0, 15],
Extrapolation.CLAMP
);
return {
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ rotate: `${rotate}deg` },
],
};
});
return (
{label}
);
}
const styles = StyleSheet.create({
card: {
width: SCREEN_WIDTH * 0.85,
height: 420,
borderRadius: 20,
backgroundColor: '#ffffff',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
elevation: 5,
},
label: { fontSize: 22, fontWeight: '600', color: '#111' },
});
Why use runOnJS here?
Reanimated worklets execute on the UI thread. React state updates must happen on the JS thread. The runOnJS wrapper bridges that gap safely. Calling a state setter directly inside a worklet will throw a runtime error in Reanimated 3.
Step 4: Build the Card Stack
Create components/CardStack.tsx. This component manages the deck and removes cards as they are swiped away.
import React, { useState } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import SwipeCard from './SwipeCard';
const CARDS = [
{ id: '1', label: 'Card One' },
{ id: '2', label: 'Card Two' },
{ id: '3', label: 'Card Three' },
{ id: '4', label: 'Card Four' },
];
export default function CardStack() {
const [deck, setDeck] = useState(CARDS);
const [lastAction, setLastAction] = useState('');
const handleSwipe = (direction: 'left' | 'right' | 'up') => {
setLastAction(`Swiped ${direction}`);
setDeck((prev) => prev.slice(0, -1));
};
if (deck.length === 0) {
return (
All cards reviewed
);
}
return (
{deck.map((card, index) => {
const isTop = index === deck.length - 1;
if (!isTop && index < deck.length - 3) return null;
return (
{}}
/>
);
})}
{lastAction ? {lastAction} : null}
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
emptyText: { fontSize: 18, color: '#555' },
action: { position: 'absolute', bottom: 60, fontSize: 16, color: '#888' },
});
Pro tip: Rendering only the top three cards (not the full deck) avoids unnecessary layout calculations. On a 50-card deck, this alone reduces initial render time by roughly 40% on a mid-range Android device.
Step 5: Add Visual Feedback Overlays
When should you add like and nope indicators?
Immediately. Users on platforms like Tinder or Bumble have been conditioned to expect colour cues. Without them, a swipe feels broken even when it is technically working. Add opacity-driven overlays that appear as the card moves.
Inside SwipeCard.tsx, add two animated overlay views after the existing animated style.
const likeOpacity = useAnimatedStyle(() => ({
opacity: interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolation.CLAMP),
}));
const nopeOpacity = useAnimatedStyle(() => ({
opacity: interpolate(translateX.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolation.CLAMP),
}));
// Inside the Animated.View, add these siblings:
//
// LIKE
//
//
// NOPE
//
Style them as bordered labels in the top corners of the card. Keep the border a solid 3px line for readability on OLED screens.
Step 6: Test on a Physical Device
Simulators throttle the GPU. A swipe animation that looks smooth in Xcode Simulator can still drop frames on a real iPhone 13 running a debug build. Always test gesture-heavy UIs on hardware before shipping.
Run a production-mode build for accurate performance numbers.
npx expo run:ios --configuration Release
npx expo run:android --variant release
Enable the Reanimated FPS monitor in development by setting __DEV__ guards around console.log calls inside worklets. Logging inside a worklet in production build mode will cause a crash on Android.
Frequently Asked Questions
Does this work with Expo Go?
Reanimated 3 and Gesture Handler 2 are supported in Expo Go as of Expo SDK 52. You do not need a custom dev client for basic usage, but production builds should use eas build to avoid runtime mismatches.
How do I prevent the card beneath from being swiped by accident?
Only attach the gesture to the top card. In the CardStack component above, the onSwipe prop is passed as a no-op for all cards except the top one. Gesture Handler respects the z-order of views, so underlying cards will not receive touch events as long as the top card fills the hit area.
What if the animation feels too stiff or too bouncy?
Tune the withSpring config. Pass a second argument with damping and stiffness values. A damping of 20 and stiffness of 200 gives a fast, crisp snap. A damping of 10 and stiffness of 80 gives a slower, bouncier feel more suitable for playful UIs.
Can I add a programmatic swipe button that triggers the same animation?
Yes. Store translateX and translateY in a ref and expose a method that sets their values using withSpring. Wrap the component in useImperativeHandle to call it from a parent button press. This pattern keeps animation logic inside the card component rather than leaking it upward.
How is Reanimated 3 different from the older Animated API?
The built-in Animated API interpolates values on the JS thread, which means animations pause when the JS thread is busy rendering. Reanimated 3 worklets run on the UI thread independently of JS, achieving consistent 60 or 120fps even during heavy list renders. As of React Native 0.76, Reanimated 3 is the recommended approach for any gesture-driven animation.
Next Steps
From here, consider wiring each swipe direction to a real data action. A right swipe could trigger an API call to save a product to a wishlist. A left swipe could log a rejection event for recommendation tuning. An upward swipe could open a detail sheet using a Bottom Sheet library like @gorhom/bottom-sheet, which is fully compatible with Gesture Handler 2.
You could also extend the overlay system to show percentage-based feedback as the card moves, giving users a sense of how close they are to the swipe threshold. Teams at Lenka Studio use this pattern in product recommendation flows for e-commerce clients across Australia and Singapore, where swipe-based discovery UIs consistently outperform traditional grid browsing on mobile in session time metrics.
If you are building a React Native app and want a gesture-driven UI that feels native rather than web-ported, the Lenka Studio team can help you design and ship it end to end. Get in touch to talk through your project.




