Swipeable Stack

PreviousNext

A deck of interactive cards with swipe gestures

Docs
animbitscomponent

Preview

Loading preview…
registry/new-york/animations/specials/swipeable-stack.tsx
"use client";

import React, { useState } from "react";
import { motion, useMotionValue, useTransform, useAnimation, PanInfo, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";

interface SwipeableStackProps {
    children: React.ReactNode[];
    className?: string;
    onSwipeRight?: (index: number) => void;
    onSwipeLeft?: (index: number) => void;
}

export function SwipeableStack({ children, className, onSwipeRight, onSwipeLeft }: SwipeableStackProps) {
    const [cards, setCards] = useState(React.Children.toArray(children));
    const [history, setHistory] = useState<React.ReactNode[]>([]);

    const activeCard = cards[cards.length - 1];

    const removeCard = (direction: "left" | "right") => {
        const removed = cards.pop();
        if (removed) {
            setHistory([...history, removed]);
            const index = cards.length;
            if (direction === "right" && onSwipeRight) onSwipeRight(index);
            if (direction === "left" && onSwipeLeft) onSwipeLeft(index);
            setCards([...cards]);
        }
    };

    return (
        <div className={cn("relative h-full w-full flex items-center justify-center", className)}>
            <AnimatePresence>
                {cards.map((child, index) => {
                    const isFront = index === cards.length - 1;
                    return (
                        <Card
                            key={index}
                            index={index}
                            isFront={isFront}
                            drag={isFront}
                            total={cards.length}
                            onRemove={removeCard}
                        >
                            {child}
                        </Card>
                    );
                })}
            </AnimatePresence>
            {cards.length === 0 && (
                <div className="text-center text-neutral-500">
                    No more items
                    <button
                        onClick={() => {
                            setCards(React.Children.toArray(children));
                            setHistory([]);
                        }}
                        className="mt-4 block mx-auto text-blue-500 hover:underline"
                    >
                        Reset
                    </button>
                </div>
            )}
        </div>
    );
}

function Card({
    children,
    index,
    isFront,
    drag,
    total,
    onRemove
}: {
    children: React.ReactNode;
    index: number;
    isFront: boolean;
    drag: boolean;
    total: number;
    onRemove: (dir: "left" | "right") => void;
}) {
    const x = useMotionValue(0);
    const rotate = useTransform(x, [-200, 200], [-25, 25]);
    const opacity = useTransform(x, [-150, 0, 150], [0.5, 1, 0.5]);

    // Stacking logic
    const reverseIndex = total - 1 - index;
    const scale = 1 - reverseIndex * 0.05;
    const y = reverseIndex * 15;
    const zIndex = total - reverseIndex;

    const controls = useAnimation();

    const handleDragEnd = async (_: any, info: PanInfo) => {
        if (info.offset.x > 100) {
            await controls.start({ x: 500, opacity: 0 });
            onRemove("right");
        } else if (info.offset.x < -100) {
            await controls.start({ x: -500, opacity: 0 });
            onRemove("left");
        } else {
            controls.start({ x: 0 });
        }
    };

    return (
        <motion.div
            style={{
                x,
                rotate,
                scale: isFront ? 1 : scale,
                y: isFront ? 0 : y,
                zIndex,
            }}
            initial={{ scale, y }}
            animate={controls}
            drag={drag ? "x" : false}
            dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}
            dragElastic={0.7}
            onDragEnd={handleDragEnd}
            className="absolute left-0 right-0 top-0 bottom-0 mx-auto cursor-grab active:cursor-grabbing origin-bottom"
            whileDrag={{ scale: 1.05 }}
        >
            <div className="h-full w-full rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 shadow-xl overflow-hidden">
                {children}
            </div>
        </motion.div>
    );
}

Installation

npx shadcn@latest add @animbits/specials-swipeable-stack

Usage

import { SpecialsSwipeableStack } from "@/components/specials-swipeable-stack"
<SpecialsSwipeableStack />