Skip to content
jjswnth/ui

Motion

Shuffle Cards

A draggable card stack that cycles on click, drag, or a timer.

v2.4

Motion primitives

Drag or click to send this card back.

v2.3

Dark mode tokens

Drag or click to send this card back.

v2.2

Dock magnification

Drag or click to send this card back.

v2.1

Copy-paste registry

Drag or click to send this card back.

Installation

  1. 1. Install the dependencies.

    terminal
    npm install motion clsx tailwind-merge
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/shuffle-cards.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { useCallback, useEffect, useState, type ReactNode } from "react";
    import { cn } from "@/lib/utils";
    
    type ShuffleCardsProps = {
      /** One node per card. The first entry starts on top. */
      cards: ReactNode[];
      className?: string;
      /** Vertical offset between stacked cards, in pixels. */
      offset?: number;
      /** Scale lost by each card further down the stack. */
      scaleStep?: number;
      /** Milliseconds between automatic shuffles. Set to 0 to disable. */
      interval?: number;
    };
    
    export function ShuffleCards({
      cards,
      className,
      offset = 16,
      scaleStep = 0.05,
      interval = 3600,
    }: ShuffleCardsProps) {
      const [order, setOrder] = useState(() => cards.map((_, index) => index));
      const [paused, setPaused] = useState(false);
    
      const shuffle = useCallback(() => {
        setOrder((current) => [...current.slice(1), current[0]!]);
      }, []);
    
      useEffect(() => {
        if (!interval || paused) return;
        const id = window.setInterval(shuffle, interval);
        return () => window.clearInterval(id);
      }, [interval, paused, shuffle]);
    
      return (
        <div
          className={cn("relative h-64 w-full max-w-sm select-none", className)}
          onMouseEnter={() => setPaused(true)}
          onMouseLeave={() => setPaused(false)}
          style={{ perspective: 1200 }}
        >
          <AnimatePresence initial={false}>
            {order.map((cardIndex, position) => {
              const depth = order.length - position;
    
              return (
                <motion.div
                  key={cardIndex}
                  layout
                  drag={position === 0 ? "x" : false}
                  dragConstraints={{ left: 0, right: 0 }}
                  dragElastic={0.6}
                  onDragEnd={(_, info) => {
                    if (Math.abs(info.offset.x) > 80) shuffle();
                  }}
                  onClick={() => position === 0 && shuffle()}
                  animate={{
                    y: position * offset,
                    scale: 1 - position * scaleStep,
                    opacity: position > 3 ? 0 : 1,
                  }}
                  transition={{ type: "spring", stiffness: 260, damping: 28 }}
                  style={{ zIndex: depth }}
                  className={cn(
                    "absolute inset-x-0 top-0 h-52 overflow-hidden rounded-card border border-line bg-surface",
                    "shadow-[0_18px_45px_-24px_rgb(0_0_0/0.5)]",
                    position === 0 ? "cursor-grab active:cursor-grabbing" : "pointer-events-none",
                  )}
                >
                  {cards[cardIndex]}
                </motion.div>
              );
            })}
          </AnimatePresence>
        </div>
      );
    }

Props

PropTypeDefaultDescription
cardsReactNode[]One node per card. The first entry starts on top.
offsetnumber16Vertical offset between stacked cards, in pixels.
scaleStepnumber0.05Scale lost by each card further down the stack.
intervalnumber3600Milliseconds between auto-shuffles. 0 disables it.
classNamestringMerged onto the stack container.