Swipe Deck
Tinder-style swipeable card stack — fling physics, rotation while dragging, and the next card scaling up underneath.
Leo, 31
Street photography and ramen.
LIKENOPE
Maya, 27
Coffee, climbing, code.
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>
);
}