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