A draggable swipe stack with real depth — cards recede on translateZ into a 3D fan; flick the top card off and the pile springs forward.
npx shadcn@latest add @paragon/card-stack-3d"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* CardStack3D — a fanned swipe stack with genuine depth. The cards behind the
* top one recede on translateZ (in a preserve-3d scene with real perspective)
* and tilt, so the pile reads as a 3D fan rather than flat scaled layers. Drag
* the top card; past the threshold — or on a flick — it lifts off toward the
* pointer with a slight 3D tilt and cycles to the back while the stack springs
* forward. Arrow keys advance for keyboard users.
*
* Distinct from swipe-card-deck (a flat triage pile). Pass `items`; otherwise
* deterministic gradient cards render. Reduced motion snaps without the fling.
*/
export interface CardStack3DProps
extends Omit<React.ComponentProps<"div">, "children"> {
items?: React.ReactNode[];
/** Number of generated cards when `items` is omitted. */
count?: number;
/** How many cards are visible in the fan at once. */
visible?: number;
/** Per-card recede on Z, px (depth of the fan). */
depth?: number;
/** Per-card downward tilt on X, degrees. */
tilt?: number;
/** Commit distance, px. */
threshold?: number;
onCycle?: (topIndex: number) => void;
}
const CARD_GRADIENTS = [
"linear-gradient(160deg, oklch(0.72 0.16 250), oklch(0.5 0.2 285))",
"linear-gradient(160deg, oklch(0.78 0.15 165), oklch(0.55 0.16 200))",
"linear-gradient(160deg, oklch(0.82 0.16 70), oklch(0.64 0.19 38))",
"linear-gradient(160deg, oklch(0.75 0.17 18), oklch(0.55 0.18 348))",
"linear-gradient(160deg, oklch(0.76 0.13 300), oklch(0.52 0.16 262))",
"linear-gradient(160deg, oklch(0.8 0.14 200), oklch(0.6 0.14 236))",
];
function GeneratedCard({ i }: { i: number }) {
return (
<div
className="flex size-full flex-col justify-between rounded-3xl p-5"
style={{ background: CARD_GRADIENTS[i % CARD_GRADIENTS.length] }}
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wide text-white/70">
Portfolio
</span>
<span className="text-xs font-semibold tabular-nums text-white/70">
{String(i + 1).padStart(2, "0")}
</span>
</div>
<div>
<p className="text-2xl font-semibold text-white drop-shadow-[0_1px_4px_rgba(0,0,0,0.35)]">
Asset {String.fromCharCode(65 + (i % 26))}
</p>
<p className="text-sm text-white/80">Swipe to review next</p>
</div>
</div>
);
}
const FLICK_VELOCITY = 500;
export function CardStack3D({
items,
count = 6,
visible = 4,
depth = 60,
tilt = 6,
threshold = 120,
onCycle,
className,
style,
...props
}: CardStack3DProps) {
const reduced = useReducedMotion() ?? false;
const cards = React.useMemo<React.ReactNode[]>(() => {
if (items && items.length) return items;
return Array.from({ length: count }, (_, i) => (
<GeneratedCard key={i} i={i} />
));
}, [items, count]);
const n = cards.length;
const [top, setTop] = React.useState(0);
const [flung, setFlung] = React.useState<{ id: number; dir: number } | null>(
null,
);
const order = React.useMemo(() => {
// Indices from top of pile downward, wrapping.
return Array.from({ length: Math.min(visible + 1, n) }, (_, k) => ({
idx: (top + k) % n,
depthLevel: k,
}));
}, [top, visible, n]);
const advance = React.useCallback(
(dir: number) => {
const flying = top;
setFlung({ id: flying, dir });
setTop((t) => {
const next = (t + 1) % n;
onCycle?.(next);
return next;
});
},
[top, n, onCycle],
);
return (
<div
role="group"
aria-label="Card stack — arrow keys to advance"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
e.preventDefault();
advance(1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
e.preventDefault();
advance(-1);
}
}}
className={cn(
"relative mx-auto h-72 w-60 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
style={{ perspective: 1200, ...style }}
{...props}
>
<div
className="relative size-full"
style={{ transformStyle: "preserve-3d" }}
>
{/* Behind cards, back to front so DOM order is safe. */}
{order
.slice()
.reverse()
.map(({ idx, depthLevel }) => {
if (depthLevel === 0) return null;
const level = Math.min(depthLevel, visible);
return (
<div
key={idx}
aria-hidden
className="absolute inset-0"
style={{
transformStyle: "preserve-3d",
transform: `translateZ(${-level * depth}px) translateY(${level * 10}px) rotateX(${level * tilt}deg)`,
transition: reduced
? undefined
: "transform 320ms var(--ease-out)",
zIndex: visible - level,
}}
>
<div className="size-full overflow-hidden rounded-3xl shadow-overlay [backface-visibility:hidden]">
{cards[idx]}
</div>
</div>
);
})}
<AnimatePresence>
{order[0] && flung?.id !== order[0].idx && (
<TopCard
key={order[0].idx}
reduced={reduced}
threshold={threshold}
onCommit={advance}
>
{cards[order[0].idx]}
</TopCard>
)}
</AnimatePresence>
{/* The card currently flying off. */}
<AnimatePresence
onExitComplete={() => setFlung(null)}
>
{flung && (
<motion.div
key={`fly-${flung.id}`}
aria-hidden
className="absolute inset-0 z-30"
style={{ transformStyle: "preserve-3d" }}
initial={{ x: 0, y: 0, rotate: 0, opacity: 1 }}
animate={
reduced
? { opacity: 0 }
: {
x: flung.dir * 360,
y: -40,
rotate: flung.dir * 18,
z: 120,
opacity: 0,
}
}
exit={{ opacity: 0 }}
transition={{ duration: reduced ? 0.15 : 0.42, ease: [0.4, 0, 1, 1] }}
>
<div className="size-full overflow-hidden rounded-3xl shadow-overlay [backface-visibility:hidden]">
{cards[flung.id]}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
function TopCard({
reduced,
threshold,
onCommit,
children,
}: {
reduced: boolean;
threshold: number;
onCommit: (dir: number) => void;
children: React.ReactNode;
}) {
const x = useMotionValue(0);
const y = useMotionValue(0);
const rotate = useTransform(x, [-200, 200], [-14, 14]);
const rotateY = useTransform(x, [-200, 200], [10, -10]);
return (
<motion.div
className="absolute inset-0 z-20 cursor-grab touch-none active:cursor-grabbing"
style={{ x, y, rotate, rotateY, transformStyle: "preserve-3d" }}
drag={reduced ? false : true}
dragElastic={0.6}
dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}
onDragEnd={(_, info) => {
const committed =
Math.abs(info.offset.x) > threshold ||
Math.abs(info.velocity.x) > FLICK_VELOCITY;
if (committed) onCommit(info.offset.x >= 0 ? 1 : -1);
}}
initial={{ scale: reduced ? 1 : 0.96, opacity: reduced ? 1 : 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<div className="size-full overflow-hidden rounded-3xl shadow-overlay [backface-visibility:hidden]">
{children}
</div>
</motion.div>
);
}