AniUI

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