React Native Swipe Cards
Tinder-style swipeable card stack for React Native with PanResponder gestures, rotation on drag, like/nope overlays, and spring-back animation.
Expo Snack
Code
import React, { useCallback, useRef, useState } from "react";
import {
Animated,
Dimensions,
Image,
PanResponder,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
const SWIPE_THRESHOLD = 120;
const SWIPE_OUT_DURATION = 300;
const MAX_VISIBLE_CARDS = 3;
const CARD_SCALE_STEP = 0.05;
const CARD_VERTICAL_OFFSET = 8;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface SwipeCardStackProps<T> {
data: T[];
renderCard: (item: T) => React.ReactNode;
onSwipeLeft?: (item: T) => void;
onSwipeRight?: (item: T) => void;
onEmpty?: () => void;
}
// ---------------------------------------------------------------------------
// SwipeCardStack
// ---------------------------------------------------------------------------
function SwipeCardStack<T>({
data,
renderCard,
onSwipeLeft,
onSwipeRight,
onEmpty,
}: SwipeCardStackProps<T>) {
const [currentIndex, setCurrentIndex] = useState(0);
const position = useRef(new Animated.ValueXY()).current;
const nextCardScale = useRef(new Animated.Value(1 - CARD_SCALE_STEP)).current;
// Derived animated values
const rotate = position.x.interpolate({
inputRange: [-SCREEN_WIDTH, 0, SCREEN_WIDTH],
outputRange: ["-12deg", "0deg", "12deg"],
extrapolate: "clamp",
});
const likeOpacity = position.x.interpolate({
inputRange: [0, SWIPE_THRESHOLD],
outputRange: [0, 1],
extrapolate: "clamp",
});
const nopeOpacity = position.x.interpolate({
inputRange: [-SWIPE_THRESHOLD, 0],
outputRange: [1, 0],
extrapolate: "clamp",
});
const animateSwipeOut = useCallback(
(direction: "left" | "right") => {
const toX = direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5;
Animated.parallel([
Animated.spring(position, {
toValue: { x: toX, y: 0 },
useNativeDriver: true,
speed: 20,
bounciness: 2,
}),
Animated.spring(nextCardScale, {
toValue: 1,
useNativeDriver: true,
speed: 14,
bounciness: 6,
}),
]).start(() => {
const swipedItem = data[currentIndex];
if (direction === "right") {
onSwipeRight?.(swipedItem);
} else {
onSwipeLeft?.(swipedItem);
}
const nextIndex = currentIndex + 1;
position.setValue({ x: 0, y: 0 });
nextCardScale.setValue(1 - CARD_SCALE_STEP);
setCurrentIndex(nextIndex);
if (nextIndex >= data.length) {
onEmpty?.();
}
});
},
[currentIndex, data, onSwipeLeft, onSwipeRight, onEmpty, position, nextCardScale]
);
const springBack = useCallback(() => {
Animated.spring(position, {
toValue: { x: 0, y: 0 },
useNativeDriver: true,
speed: 16,
bounciness: 8,
}).start();
}, [position]);
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderMove: (_, gesture) => {
position.setValue({ x: gesture.dx, y: gesture.dy * 0.4 });
},
onPanResponderRelease: (_, gesture) => {
if (gesture.dx > SWIPE_THRESHOLD) {
animateSwipeOut("right");
} else if (gesture.dx < -SWIPE_THRESHOLD) {
animateSwipeOut("left");
} else {
springBack();
}
},
})
).current;
const handleButtonSwipe = useCallback(
(direction: "left" | "right") => {
if (currentIndex >= data.length) return;
animateSwipeOut(direction);
},
[currentIndex, data.length, animateSwipeOut]
);
// Render empty state
if (currentIndex >= data.length) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No more cards!</Text>
</View>
);
}
// Render visible cards (bottom-up so top card renders last)
const visibleCards = data
.slice(currentIndex, currentIndex + MAX_VISIBLE_CARDS)
.map((item, i) => {
const isTopCard = i === 0;
if (isTopCard) {
const animatedStyle = {
transform: [...position.getTranslateTransform(), { rotate }],
};
return (
<Animated.View
key={`card-${currentIndex}`}
style={[styles.card, animatedStyle, { zIndex: MAX_VISIBLE_CARDS }]}
{...panResponder.panHandlers}
>
{renderCard(item)}
{/* LIKE overlay */}
<Animated.View style={[styles.overlay, styles.likeOverlay, { opacity: likeOpacity }]}>
<Text style={[styles.overlayText, styles.likeText]}>LIKE</Text>
</Animated.View>
{/* NOPE overlay */}
<Animated.View style={[styles.overlay, styles.nopeOverlay, { opacity: nopeOpacity }]}>
<Text style={[styles.overlayText, styles.nopeText]}>NOPE</Text>
</Animated.View>
</Animated.View>
);
}
// Background cards
const scale = i === 1 ? nextCardScale : 1 - CARD_SCALE_STEP * i;
const translateY = CARD_VERTICAL_OFFSET * i;
return (
<Animated.View
key={`card-${currentIndex + i}`}
style={[
styles.card,
{
zIndex: MAX_VISIBLE_CARDS - i,
transform: [{ scale }, { translateY }],
},
]}
>
{renderCard(item)}
</Animated.View>
);
})
.reverse();
return (
<View style={styles.stackContainer}>
<View style={styles.cardArea}>{visibleCards}</View>
{/* Action buttons */}
<View style={styles.buttonsRow}>
<TouchableOpacity
style={[styles.actionButton, styles.nopeButton]}
onPress={() => handleButtonSwipe("left")}
activeOpacity={0.8}
>
<Text style={styles.nopeButtonText}>NOPE</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.likeButton]}
onPress={() => handleButtonSwipe("right")}
activeOpacity={0.8}
>
<Text style={styles.likeButtonText}>LIKE</Text>
</TouchableOpacity>
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// Demo App
// ---------------------------------------------------------------------------
interface Profile {
id: number;
name: string;
age: number;
bio: string;
image: string;
}
const PROFILES: Profile[] = [
{
id: 1,
name: "Alex",
age: 28,
bio: "Loves hiking, coffee, and spontaneous road trips.",
image: "https://picsum.photos/seed/alex/400/500",
},
{
id: 2,
name: "Jordan",
age: 24,
bio: "Music producer by day, cat parent by night.",
image: "https://picsum.photos/seed/jordan/400/500",
},
{
id: 3,
name: "Morgan",
age: 31,
bio: "Bookworm and amateur chef. Let's swap recipes!",
image: "https://picsum.photos/seed/morgan/400/500",
},
{
id: 4,
name: "Taylor",
age: 26,
bio: "Yoga instructor who also writes terrible poetry.",
image: "https://picsum.photos/seed/taylor/400/500",
},
{
id: 5,
name: "Casey",
age: 29,
bio: "Software engineer. Fluent in TypeScript and sarcasm.",
image: "https://picsum.photos/seed/casey/400/500",
},
];
function ProfileCard({ profile }: { profile: Profile }) {
return (
<View style={profileStyles.container}>
<Image source={{ uri: profile.image }} style={profileStyles.image} />
<View style={profileStyles.info}>
<Text style={profileStyles.name}>
{profile.name}, {profile.age}
</Text>
<Text style={profileStyles.bio}>{profile.bio}</Text>
</View>
</View>
);
}
export default function App() {
return (
<View style={appStyles.container}>
<Text style={appStyles.title}>Discover</Text>
<SwipeCardStack
data={PROFILES}
renderCard={(item) => <ProfileCard profile={item} />}
onSwipeLeft={(item) => console.log("Nope:", item.name)}
onSwipeRight={(item) => console.log("Like:", item.name)}
onEmpty={() => console.log("No more profiles!")}
/>
</View>
);
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = StyleSheet.create({
stackContainer: {
flex: 1,
alignItems: "center",
},
cardArea: {
flex: 1,
width: SCREEN_WIDTH - 40,
alignItems: "center",
justifyContent: "center",
},
card: {
position: "absolute",
width: SCREEN_WIDTH - 40,
height: SCREEN_HEIGHT * 0.55,
borderRadius: 20,
overflow: "hidden",
backgroundColor: "#1e293b",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.4,
shadowRadius: 16,
elevation: 10,
},
overlay: {
position: "absolute",
top: 40,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 3,
},
likeOverlay: {
left: 24,
borderColor: "#22c55e",
backgroundColor: "rgba(34, 197, 94, 0.15)",
},
nopeOverlay: {
right: 24,
borderColor: "#ef4444",
backgroundColor: "rgba(239, 68, 68, 0.15)",
},
overlayText: {
fontSize: 32,
fontWeight: "800",
letterSpacing: 2,
},
likeText: {
color: "#22c55e",
},
nopeText: {
color: "#ef4444",
},
buttonsRow: {
flexDirection: "row",
gap: 32,
paddingBottom: 40,
paddingTop: 16,
},
actionButton: {
width: 72,
height: 72,
borderRadius: 36,
alignItems: "center",
justifyContent: "center",
borderWidth: 2,
},
nopeButton: {
borderColor: "#ef4444",
backgroundColor: "rgba(239, 68, 68, 0.1)",
},
likeButton: {
borderColor: "#22c55e",
backgroundColor: "rgba(34, 197, 94, 0.1)",
},
nopeButtonText: {
color: "#ef4444",
fontSize: 13,
fontWeight: "800",
letterSpacing: 1,
},
likeButtonText: {
color: "#22c55e",
fontSize: 13,
fontWeight: "800",
letterSpacing: 1,
},
emptyContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
emptyText: {
color: "#94a3b8",
fontSize: 20,
fontWeight: "600",
},
});
const profileStyles = StyleSheet.create({
container: {
flex: 1,
},
image: {
width: "100%",
height: "70%",
resizeMode: "cover",
},
info: {
flex: 1,
padding: 20,
justifyContent: "center",
},
name: {
color: "#f8fafc",
fontSize: 26,
fontWeight: "700",
marginBottom: 6,
},
bio: {
color: "#94a3b8",
fontSize: 15,
lineHeight: 22,
},
});
const appStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f172a",
paddingTop: 60,
},
title: {
color: "#f8fafc",
fontSize: 28,
fontWeight: "800",
textAlign: "center",
marginBottom: 8,
},
});React Native Swipe Cards
A Tinder-style swipeable card stack built entirely with React Native’s built-in PanResponder and Animated APIs. Cards are stacked with the top card fully interactive while subsequent cards sit slightly scaled down behind it. Dragging horizontally rotates the card proportionally, fading in a green “LIKE” or red “NOPE” overlay depending on swipe direction. Releasing past a configurable threshold flings the card off-screen with a spring animation, while releasing below the threshold snaps the card back to center.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
data | T[] | Yes | Array of items to render as cards. |
renderCard | (item: T) => React.ReactNode | Yes | Render function for each card’s content. |
onSwipeLeft | (item: T) => void | No | Callback fired when a card is swiped left (rejected). |
onSwipeRight | (item: T) => void | No | Callback fired when a card is swiped right (liked). |
onEmpty | () => void | No | Callback fired when all cards have been swiped. |
Usage
import SwipeCardStack from "./SwipeCardStack";
const profiles = [
{ id: 1, name: "Alex", age: 28, bio: "Loves hiking and coffee." },
{ id: 2, name: "Jordan", age: 24, bio: "Music producer & cat parent." },
];
<SwipeCardStack
data={profiles}
renderCard={(item) => <ProfileCard profile={item} />}
onSwipeLeft={(item) => console.log("Nope:", item.name)}
onSwipeRight={(item) => console.log("Like:", item.name)}
onEmpty={() => console.log("No more cards!")}
/>;
How it works
- Card stacking — Cards are rendered in reverse order using
position: "absolute"so the first item in the array sits on top. Cards behind the active card are progressively scaled down and shifted vertically to create a deck illusion. - PanResponder gestures — A
PanResponderis attached to the top card. OnonPanResponderMove, the card’stranslateXanimated value is updated to follow the finger, and a derivedrotateinterpolation tilts the card proportionally (up to ~12 degrees). - Like / Nope overlays — Two absolutely-positioned overlays (“LIKE” in green, “NOPE” in red) sit inside each card. Their opacity is interpolated from
translateX: swiping right fades in the LIKE label, swiping left fades in NOPE. - Threshold release — On
onPanResponderRelease, if the horizontal displacement exceeds a threshold (default 120px), the card is animated off-screen usingAnimated.spring. Otherwise, it springs back to{ x: 0, y: 0 }. - Deck advancement — After the exit animation completes, the current index increments and the next card’s scale animates from its resting value up to 1, smoothly “promoting” it to the active position.