Galleries & Media

Card Stack 3D

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.

Install

npx shadcn@latest add @paragon/card-stack-3d

card-stack-3d.tsx

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