AniUI

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-deck

Requires 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
data
T[]
—
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.

className
string
—

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>
  );
}