Build Swipeable Cards in React Native (Swipe Deck)
Updated
React Native swipe cards work best when a Gesture Handler pan gesture writes directly to one Reanimated shared value, which then drives translation, rotation, decision feedback, and the card stack on the UI thread. This tutorial builds the classic swipe-to-decide card deck with Expo SDK 57, Gesture Handler 2.32, Reanimated 4.5.1, and Worklets 0.10.1.
What you'll build
You will create a small decision deck that follows the finger, rotates naturally, reveals LIKE or PASS feedback, springs home below a threshold, and exits when distance or velocity indicates intent. A single horizontal progress value controls every frame of the gesture, and scheduleOnRN moves the completed decision back to React only after the exit animation finishes.
The deck includes buttons that invoke the same decision path. That makes the interaction usable without a drag gesture and gives keyboard, switch-control, and screen-reader users an equivalent action.
Prerequisites
Use Expo SDK 57, React Native 0.86, Reanimated 4.5.1, react-native-worklets 0.10.1, and react-native-gesture-handler ~2.32. This simplified effect does not require Skia, Blur, Linear Gradient, or Haptics.
Install the SDK-compatible packages:
npx expo install react-native-gesture-handler react-native-reanimated react-native-workletsYour app root must be under GestureHandlerRootView; current Expo templates commonly set this up, but verify your root if gestures never activate. Consult the official Gesture Handler installation guide for the installed version, and use the current Reanimated spring API rather than an older configuration.
Model the gesture as one progress value
Use horizontal translation in points as the source of truth. Rotation is translation divided by card width, decision opacity is translation divided by the release distance, and the card behind scales up as the absolute translation grows. These are not independent animations; they are different views of the same gesture.
React state should change only when a card has actually been accepted or rejected. During the drag, the UI thread owns the progress value and React does no work per frame.
Build the swipe deck
Create SwipeDeck.tsx:
import { useCallback, useMemo, useState } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
Extrapolation,
interpolate,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
type Card = { id: string; title: string; detail: string };
type Direction = 'left' | 'right';
type SwipeDeckProps = {
cards: Card[];
onSwipe?: (card: Card, direction: Direction) => void;
};
const RETURN_SPRING = { damping: 16, stiffness: 210, mass: 0.75 };
const EXIT_SPRING = { damping: 18, stiffness: 95, mass: 0.8 };
export function SwipeDeck({ cards, onSwipe }: SwipeDeckProps) {
const { width } = useWindowDimensions();
const cardWidth = Math.min(width - 40, 380);
const releaseDistance = cardWidth * 0.28;
const [activeIndex, setActiveIndex] = useState(0);
const reducedMotion = useReducedMotion();
const progress = useSharedValue(0);
const commit = useCallback(
(direction: Direction) => {
const card = cards[activeIndex];
if (!card) return;
onSwipe?.(card, direction);
setActiveIndex((index) => index + 1);
},
[activeIndex, cards, onSwipe],
);
const exit = useCallback(
(direction: Direction, velocity = 0) => {
if (!cards[activeIndex]) return;
if (reducedMotion) {
progress.set(0);
commit(direction);
return;
}
const target = (direction === 'right' ? 1 : -1) * width * 1.35;
progress.set(
withSpring(
target,
{ ...EXIT_SPRING, velocity },
(finished?: boolean) => {
if (!finished) return;
progress.set(0);
scheduleOnRN(commit, direction);
},
),
);
},
[activeIndex, cards, commit, progress, reducedMotion, width],
);
const pan = useMemo(
() =>
Gesture.Pan()
.activeOffsetX([-8, 8])
.failOffsetY([-18, 18])
.onChange((event) => {
progress.set(event.translationX);
})
.onFinalize((event) => {
const x = progress.get();
const shouldExit =
Math.abs(x) > releaseDistance || Math.abs(event.velocityX) > 900;
if (!shouldExit) {
progress.set(
reducedMotion ? 0 : withSpring(0, RETURN_SPRING),
);
return;
}
const direction: Direction =
x === 0
? event.velocityX >= 0
? 'right'
: 'left'
: x > 0
? 'right'
: 'left';
const target = (direction === 'right' ? 1 : -1) * width * 1.35;
if (reducedMotion) {
progress.set(0);
scheduleOnRN(commit, direction);
return;
}
progress.set(
withSpring(
target,
{ ...EXIT_SPRING, velocity: event.velocityX },
(finished?: boolean) => {
if (!finished) return;
progress.set(0);
scheduleOnRN(commit, direction);
},
),
);
}),
[commit, progress, reducedMotion, releaseDistance, width],
);
const frontStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: progress.get() },
{
rotate:
interpolate(
progress.get(),
[-cardWidth, 0, cardWidth],
[-16, 0, 16],
Extrapolation.CLAMP,
) + 'deg',
},
],
}));
const nextStyle = useAnimatedStyle(() => {
const amount = Math.min(Math.abs(progress.get()) / releaseDistance, 1);
return {
opacity: interpolate(amount, [0, 1], [0.72, 1]),
transform: [
{ translateY: interpolate(amount, [0, 1], [14, 0]) },
{ scale: interpolate(amount, [0, 1], [0.94, 1]) },
],
};
});
const likeStyle = useAnimatedStyle(() => ({
opacity: interpolate(
progress.get(),
[0, releaseDistance],
[0, 1],
Extrapolation.CLAMP,
),
}));
const passStyle = useAnimatedStyle(() => ({
opacity: interpolate(
progress.get(),
[-releaseDistance, 0],
[1, 0],
Extrapolation.CLAMP,
),
}));
const current = cards[activeIndex];
const next = cards[activeIndex + 1];
if (!current) {
return <Text accessibilityRole="summary">You reached the end.</Text>;
}
return (
<View style={styles.deck}>
{next ? (
<Animated.View style={[styles.card, { width: cardWidth }, nextStyle]}>
<Text style={styles.title}>{next.title}</Text>
<Text style={styles.detail}>{next.detail}</Text>
</Animated.View>
) : null}
<GestureDetector gesture={pan}>
<Animated.View style={[styles.card, { width: cardWidth }, frontStyle]}>
<Animated.Text style={[styles.like, likeStyle]}>LIKE</Animated.Text>
<Animated.Text style={[styles.pass, passStyle]}>PASS</Animated.Text>
<Text style={styles.title}>{current.title}</Text>
<Text style={styles.detail}>{current.detail}</Text>
</Animated.View>
</GestureDetector>
<View style={styles.actions}>
<Pressable
accessibilityLabel={'Pass on ' + current.title}
accessibilityRole="button"
onPress={() => exit('left')}
style={styles.button}
>
<Text>Pass</Text>
</Pressable>
<Pressable
accessibilityLabel={'Like ' + current.title}
accessibilityRole="button"
onPress={() => exit('right')}
style={styles.button}
>
<Text>Like</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
deck: { alignItems: 'center', height: 560, justifyContent: 'center' },
card: {
backgroundColor: '#FFFFFF',
borderRadius: 28,
boxShadow: '0px 16px 40px rgba(20,20,32,0.16)',
height: 430,
justifyContent: 'flex-end',
padding: 24,
position: 'absolute',
},
title: { color: '#17151F', fontSize: 30, fontWeight: '700' },
detail: { color: '#6F6978', fontSize: 16, marginTop: 8 },
like: { color: '#198A64', fontSize: 24, left: 24, position: 'absolute', top: 24 },
pass: { color: '#D34D66', fontSize: 24, position: 'absolute', right: 24, top: 24 },
actions: { bottom: 0, flexDirection: 'row', gap: 16, position: 'absolute' },
button: {
alignItems: 'center',
backgroundColor: '#F1EFF7',
borderRadius: 24,
minHeight: 48,
justifyContent: 'center',
paddingHorizontal: 24,
},
});Wrap the screen once if your app root does not already provide the gesture context:
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export function DeckScreen() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SwipeDeck cards={choices} onSwipe={saveDecision} />
</GestureHandlerRootView>
);
}Tune distance, velocity, and springs
Distance alone makes quick flicks feel ignored; velocity alone makes small accidental movements destructive. The example accepts either 28% of card width or 900 points per second. Start there, then test with actual users and the physical size of your cards.
The return spring is stiffer than the exit spring. Returning should feel decisive and restore the card quickly, while exit motion should preserve the throw's velocity and travel beyond the viewport. Passing event.velocityX into withSpring prevents the release from visibly losing momentum.
activeOffsetX delays recognition until horizontal intent is clear. failOffsetY lets a vertical parent scroll view win if the finger moves vertically first. Gesture composition changes when decks live inside more complex carousels, so test the real nesting rather than only an isolated screen.
Crossing from the UI thread to React
Gesture callbacks and spring completion callbacks run as worklets. Updating the React index and calling a business callback belong on the React Native runtime, so Worklets 0.10 uses scheduleOnRN(commit, direction). Do not use the deprecated runOnJS pattern in new code for this stack.
The boundary happens after the card exits. Until then, progress, rotation, stamps, and the next-card preview stay entirely on the UI thread. That separation avoids a React render while the finger is moving.
For a more reusable deck, guard against a second gesture while exit is running and keep a history stack for undo. Those features add coordination state, but the visual state should still derive from the same gesture progress.
Accessibility
Dragging cannot be the only way to decide. The Pass and Like buttons have explicit roles and labels containing the current card title, and they call the same exit function as the gesture. Their touch targets are at least 44 points tall.
When Reduce Motion is enabled, the example commits without an off-screen travel animation. The card change still occurs, and the buttons remain available. In a production deck, announce the result or new card with a polite live region, but avoid narrating continuously changing drag percentages.
Do not encode the two decisions only with red and green. The visible words, button labels, and accessible labels provide redundant meaning. If card content includes images, give the content its own useful image description.
Performance
The gesture writes one shared value. Four animated styles read it on the UI thread, so React does not rerender during dragging. React state advances once after a completed exit.
Mount only the front card and a small visible stack. Image-heavy decks can prefetch the next asset, but mounting dozens of full cards wastes memory. Keep card keys stable, use appropriately sized local or cached images, and avoid heavy shadows on many overlapping views.
Measure the container instead of assuming the window width if the deck can live in a split view, tablet layout, or padded sheet. The production piece does this; the tutorial uses useWindowDimensions to keep the core mechanics easy to see.
Test both slow drags and fast flicks near each threshold. Those boundary cases reveal accidental exits, dead-feeling releases, and gesture conflicts that a centered demo swipe will miss.
Common pitfalls
- Calling React state setters from a worklet. Use
scheduleOnRNafter the animation completes. - Ignoring release velocity. A fast flick should exit even if the finger traveled less than the distance threshold.
- Starting a new timing animation on every update. During the gesture, assign translation directly; animate only after release.
- Using separate shared values for stamps and stack depth. Derive them from horizontal progress so feedback cannot drift.
- Leaving the next card static. A subtle scale and lift communicate depth and make the deck feel physically connected.
- No non-gesture controls. A swipe-only deck excludes users who cannot perform or discover the gesture.
- Rendering the entire dataset. Keep only a small stack mounted and advance the data index after exit.
The official Pan gesture documentation explains activation and failure ranges, while scheduleOnRN documents the runtime crossing used above.
Skip the build: production-ready version
The production-ready Decision Swipe Deck adds measured responsive geometry, grip-aware rotation, multi-card fan progression, customizable stamps, controlled callbacks, imperative swipes, undo history, busy-state protection, and accessible controls. It is packaged as a configurable piece and tested on iOS, Android, and web. If your interaction is navigation rather than a decision, the Liquid Glass tab bar guide shows a scroll-driven pattern instead.
FAQ
How do I make swipe cards in React Native?
Use Gesture.Pan() to write horizontal translation into a Reanimated shared value, then derive card translation, rotation, labels, and stack depth from that value. On release, spring to zero or off-screen based on distance and velocity.
How do I detect a left or right swipe?
Check the sign of horizontal translation, with velocity as a fallback when translation is zero. Apply both a distance threshold and a velocity threshold so deliberate flicks feel responsive without making small drags destructive.
Why does my swipe card jump when I release it?
The release animation probably starts from a different state or discards gesture velocity. Animate the same shared value the gesture was updating and pass velocityX to the exit spring.
Can swipe cards be accessible?
Yes, if every swipe action also has a labeled button or accessibility action and the result is announced appropriately. Reduced Motion should remove large travel without removing the underlying choice.