Sticky Scroll Stack
Cards that pin and stack on top of each other as you scroll past.
- Category
- Patterns
- Added
- 2026-08-22
- Deps
- 1
- Updated
- 2026-09-01
Sketch the shape before you touch a component.
Wire it up with the real content, not lorem ipsum.
Small, reversible, boring deploys beat big risky ones.
Props
"use client";
import { useRef } from "react";
import { motion, useScroll, useTransform, type MotionValue } from "framer-motion";
const CARDS = [
{ title: "Design", body: "Sketch the shape before you touch a component." },
{ title: "Build", body: "Wire it up with the real content, not lorem ipsum." },
{ title: "Ship", body: "Small, reversible, boring deploys beat big risky ones." },
];
function StackCard({
card,
index,
progress,
scaleStep,
}: {
card: (typeof CARDS)[number];
index: number;
progress: MotionValue<number>;
scaleStep: number;
}) {
const start = index / CARDS.length;
const end = (index + 1) / CARDS.length;
const cardScale = useTransform(
progress,
[start, end, Math.min(end + 0.15, 1)],
[1, 1, 1 - scaleStep * (CARDS.length - index)]
);
return (
<motion.div
style={{ scale: cardScale, top: 16 + index * 12 }}
className="sticky flex h-48 w-full flex-col justify-center gap-2 rounded-2xl border border-neutral-200 bg-white p-6 shadow-lg"
>
<span className="text-lg font-black text-red-500">0{index + 1}</span>
<span className="text-xl font-bold">{card.title}</span>
<p className="text-sm text-neutral-500">{card.body}</p>
</motion.div>
);
}
export function StickyScrollStack({ scaleStep = 0.06 }: { scaleStep?: number }) {
const containerRef = useRef<HTMLDivElement>(null);
// `container` (not `target`) tracks this element's OWN internal
// overflow-y-auto scroll — `target` only tracks scroll of an ancestor
// page moving this element through the viewport, which stays frozen at 0
// when the element scrolls itself instead.
const { scrollYProgress } = useScroll({ container: containerRef });
return (
<div ref={containerRef} className="relative h-[420px] w-72 overflow-y-auto rounded-2xl border border-neutral-200">
<div style={{ height: `${CARDS.length * 260}px` }} className="relative">
{CARDS.map((card, i) => (
<StackCard key={card.title} card={card} index={i} progress={scrollYProgress} scaleStep={scaleStep} />
))}
</div>
</div>
);
}
About this pattern
Cards that scroll normally past the viewport are easy to miss; pinning each one in place and shrinking it slightly as the next card arrives gives the pile a sense of depth as you scroll through it. The part that actually breaks if handled wrong: Framer's useScroll has both a target option, which tracks an ancestor page scrolling this element through the viewport, and a container option, which tracks the element's own internal scrollbar. Since this component scrolls itself, only container produces a moving scroll progress value — target stays frozen at zero no matter how far you scroll, even while the element's own scroll position is visibly changing underneath it. The other requirement: useTransform has to be called from its own per-card subcomponent, not inside the parent's map callback, since calling a hook a variable number of times per render breaks the rules of hooks. This is a self-scrolling container pattern, not a whole-page effect — dropping it into a page without giving it its own scrollable height does nothing.
Curator’s note
Two traps in one component: calling useTransform inside the parent's .map() (fixed by giving each card its own component instance), and reaching for useScroll's target option on a self-scrolling container instead of container (target tracks an ancestor page scrolling the element through the viewport, not the element's own scrollbar — using it here left every card frozen no matter how far you scrolled).
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Hide On Scroll Navbar
patternsA navbar that slides away on scroll-down and reappears on scroll-up.
Scroll Card Deal
animationsA pile of cards that fans out symmetrically as its section crosses the viewport.
Chapter Progress Nav
componentsA sticky section nav whose items fill like progress bars as you read — and still reaches the short last section that fixed-line navs never activate.