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.
import { ShuffleCards } from "@/components/ui/shuffle-cards";
const releases = [
{ version: "v2.4", title: "Motion primitives", tone: "oklch(62% 0.19 265)" },
{ version: "v2.3", title: "Dark mode tokens", tone: "oklch(66% 0.17 20)" },
{ version: "v2.2", title: "Dock magnification", tone: "oklch(70% 0.16 150)" },
{ version: "v2.1", title: "Copy-paste registry", tone: "oklch(72% 0.15 80)" },
];
export default function ShuffleCardsDemo() {
return (
<ShuffleCards
cards={releases.map((release) => (
<div key={release.version} className="flex h-full flex-col justify-between p-6">
<span
className="w-fit rounded-full px-2.5 py-1 text-xs font-medium"
style={{
backgroundColor: `color-mix(in oklab, ${release.tone} 18%, transparent)`,
color: release.tone,
}}
>
{release.version}
</span>
<div>
<h3 className="text-lg font-semibold tracking-tight">{release.title}</h3>
<p className="mt-1 text-sm text-muted">Drag or click to send this card back.</p>
</div>
</div>
))}
/>
);
}Installation
1. Install the dependencies.
terminal npm install motion clsx tailwind-merge2. Add the
cnhelper, 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. 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
| Prop | Type | Default | Description |
|---|---|---|---|
| cards | ReactNode[] | — | One node per card. The first entry starts on top. |
| offset | number | 16 | Vertical offset between stacked cards, in pixels. |
| scaleStep | number | 0.05 | Scale lost by each card further down the stack. |
| interval | number | 3600 | Milliseconds between auto-shuffles. 0 disables it. |
| className | string | — | Merged onto the stack container. |