Stacked Cards

Cards start in a deck with slight offset, then fan apart as you scroll past — like flipping through a portfolio

cardsscrollstackframer-motionportfolio
Install dependencies
$npm install framer-motion
Preview

Source Code

"use client";

import { ReactNode, useRef } from "react";
import { motion, useScroll, useTransform, MotionValue } from "framer-motion";

interface StackedCardsProps {
  cards: ReactNode[];
  stackOffset?: number;
  rotateRange?: number;
  className?: string;
}

interface StackedCardProps {
  child: ReactNode;
  index: number;
  total: number;
  progress: MotionValue<number>;
  stackOffset: number;
  rotateRange: number;
}

function StackedCard({
  child,
  index,
  total,
  progress,
  stackOffset,
  rotateRange,
}: StackedCardProps) {
  // Each card owns a slice of the scroll progress.
  const start = index / total;
  const end = (index + 1) / total;
  const mid = start + (end - start) * 0.55;

  // Deterministic per-card tilt so each one fans a different way.
  const tilt = ((index % 2 === 0 ? 1 : -1) * rotateRange * (1 + index * 0.15));

  const exitY = -(420 + index * 20);

  const y = useTransform(
    progress,
    [start, mid, end],
    [index * stackOffset, 0, exitY]
  );
  const rotate = useTransform(progress, [start, mid, end], [0, tilt, tilt * 1.4]);
  const scale = useTransform(progress, [start, mid, end], [1 - index * 0.03, 1, 0.96]);
  const opacity = useTransform(
    progress,
    [start, mid, end - 0.02, end],
    [1, 1, 1, 0]
  );

  return (
    <motion.div
      style={{
        y,
        rotate,
        scale,
        opacity,
        zIndex: total - index,
      }}
      className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 will-change-transform"
    >
      {child}
    </motion.div>
  );
}

export default function StackedCards({
  cards,
  stackOffset = 12,
  rotateRange = 6,
  className = "",
}: StackedCardsProps) {
  const wrapperRef = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({
    target: wrapperRef,
    offset: ["start start", "end end"],
  });

  const total = cards.length || 1;

  return (
    <div
      ref={wrapperRef}
      className={`relative w-full ${className}`}
      style={{ height: `${total * 100}vh` }}
    >
      <div className="sticky top-0 flex h-screen w-full items-center justify-center overflow-hidden">
        <div className="relative h-[260px] w-[340px]">
          {cards.map((card, i) => (
            <StackedCard
              key={i}
              child={card}
              index={i}
              total={total}
              progress={scrollYProgress}
              stackOffset={stackOffset}
              rotateRange={rotateRange}
            />
          ))}
        </div>
      </div>
    </div>
  );
}