Swipe Deck
Tinder-style swipeable card stack — fling physics, rotation while dragging, and the next card scaling up underneath.
Jules, 31
Backend engineer · 5 mi away
LIKENOPE
Aria, 27
Product designer · 3 mi away
Drag the card left or right.
Web preview — components render natively on iOS & Android
import { View } from "react-native";
import { Text } from "@/components/ui/text";
import { SwipeDeck } from "@/components/ui/swipe-deck";
const profiles = [
{ name: "Maya", age: 27, bio: "Coffee, climbing, code." },
{ name: "Leo", age: 31, bio: "Street photography and ramen." },
{ name: "Ava", age: 24, bio: "Trail runner. Dog person." },
];
export function DiscoverScreen() {
return (
<SwipeDeck
data={profiles}
className="h-96"
renderCard={(profile) => (
<View className="h-96 justify-end rounded-3xl bg-secondary p-6">
<Text className="text-2xl font-bold">{profile.name}, {profile.age}</Text>
<Text variant="muted">{profile.bio}</Text>
</View>
)}
onSwipeRight={(profile) => like(profile)}
onSwipeLeft={(profile) => pass(profile)}
/>
);
}Installation#
npx @aniui/cli add swipe-deckRequires react-native-gesture-handler and react-native-reanimated — wrap your app root in GestureHandlerRootView.
app/_layout.tsx
// app/_layout.tsx — SwipeDeck's pan gesture needs the
// gesture-handler root view once, at the top of your app.
import { GestureHandlerRootView } from "react-native-gesture-handler";
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
{/* your navigator / screens */}
</GestureHandlerRootView>
);
}Usage#
SwipeDeck is generic — pass any data array and render each card with renderCard. Give the deck (and your card) an explicit height, since the cards are absolutely stacked.
app/discover.tsx
import { View } from "react-native";
import { Text } from "@/components/ui/text";
import { SwipeDeck } from "@/components/ui/swipe-deck";
const profiles = [
{ name: "Maya", age: 27, bio: "Coffee, climbing, code." },
{ name: "Leo", age: 31, bio: "Street photography and ramen." },
{ name: "Ava", age: 24, bio: "Trail runner. Dog person." },
];
export function DiscoverScreen() {
return (
<SwipeDeck
data={profiles}
className="h-96"
renderCard={(profile) => (
<View className="h-96 justify-end rounded-3xl bg-secondary p-6">
<Text className="text-2xl font-bold">{profile.name}, {profile.age}</Text>
<Text variant="muted">{profile.bio}</Text>
</View>
)}
onSwipeRight={(profile) => like(profile)}
onSwipeLeft={(profile) => pass(profile)}
/>
);
}When the deck runs out#
Empty state
// onEmpty fires after the last card is swiped away —
// swap in an EmptyState when the deck runs out.
import { EmptyState } from "@/components/ui/empty-state";
const [empty, setEmpty] = useState(false);
{empty ? (
<EmptyState
title="You're all caught up"
description="Check back later for more profiles."
/>
) : (
<SwipeDeck
data={profiles}
renderCard={renderProfile}
onEmpty={() => setEmpty(true)}
/>
)}Fling physics#
- A card is dismissed when dragged past 35% of the deck width, or flung with horizontal velocity above 900 — otherwise it springs back to center.
- The top card rotates up to ±12° with the horizontal drag.
- The next card scales from 0.95 to 1 and slides into place as the top card travels.
Props#
PropTypeDefault
dataT[]—renderCard(item: T, index: number) => ReactNode—onSwipeLeft(item: T, index: number) => void—onSwipeRight(item: T, index: number) => void—onEmpty() => void—Fired after the last card is swiped away.
classNamestring—Also accepts all View props from React Native.
Accessibility#
- The top card announces its position (“Card 2 of 5”) via
accessibilityLabel. - Swiping is gesture-only — pair the deck with visible like/pass buttons that call the same handlers for users who can’t perform the gesture.
Source#
components/ui/swipe-deck.tsx
import React, { useState } from "react";
import { View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, interpolate, runOnJS } from "react-native-reanimated";
import { cn } from "@/lib/utils";
export interface SwipeDeckProps<T> extends React.ComponentPropsWithoutRef<typeof View> {
className?: string;
data: T[];
renderCard: (item: T, index: number) => React.ReactNode;
onSwipeLeft?: (item: T, index: number) => void;
onSwipeRight?: (item: T, index: number) => void;
/** Fired after the last card is swiped away. */
onEmpty?: () => void;
}
export function SwipeDeck<T>({
className, data, renderCard, onSwipeLeft, onSwipeRight, onEmpty, onLayout, ...props
}: SwipeDeckProps<T>) {
const [index, setIndex] = useState(0);
const [width, setWidth] = useState(0);
const tx = useSharedValue(0);
const ty = useSharedValue(0);
const advance = (dir: 1 | -1) => {
const item = data[index];
(dir === 1 ? onSwipeRight : onSwipeLeft)?.(item, index);
tx.value = 0;
ty.value = 0;
const next = index + 1;
setIndex(next);
if (next >= data.length) onEmpty?.();
};
const gesture = Gesture.Pan()
.enabled(index < data.length && width > 0)
.onUpdate((e) => { tx.value = e.translationX; ty.value = e.translationY; })
.onEnd((e) => {
const threshold = width * 0.35;
if (Math.abs(tx.value) > threshold || Math.abs(e.velocityX) > 900) {
const dir: 1 | -1 = tx.value > 0 || (Math.abs(tx.value) <= threshold && e.velocityX > 0) ? 1 : -1;
tx.value = withTiming(dir * width * 1.4, { duration: 200 }, () => runOnJS(advance)(dir));
ty.value = withTiming(ty.value + e.velocityY * 0.05, { duration: 200 });
} else {
tx.value = withSpring(0);
ty.value = withSpring(0);
}
});
const topStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: tx.value },
{ translateY: ty.value },
{ rotate: `${interpolate(tx.value, [-width || -1, width || 1], [-12, 12])}deg` },
],
}));
// The card behind grows into place as the top card is dragged away.
const nextStyle = useAnimatedStyle(() => {
const progress = Math.min(1, Math.abs(tx.value) / ((width || 1) * 0.5));
return { transform: [{ scale: 0.95 + 0.05 * progress }, { translateY: 8 - 8 * progress }] };
});
const top = data[index];
const next = data[index + 1];
return (
<View
className={cn("w-full", className)}
onLayout={(e) => { setWidth(e.nativeEvent.layout.width); onLayout?.(e); }}
{...props}
>
{next !== undefined && (
<Animated.View className="absolute inset-0" style={nextStyle} pointerEvents="none">
{renderCard(next, index + 1)}
</Animated.View>
)}
{top !== undefined && (
<GestureDetector gesture={gesture}>
<Animated.View style={topStyle} accessible={true} accessibilityLabel={`Card ${index + 1} of ${data.length}`}>
{renderCard(top, index)}
</Animated.View>
</GestureDetector>
)}
</View>
);
}

