Skip to content

Sticky Scroll Stack

Cards that pin and stack on top of each other as you scroll past.

Freeeditorialminimal
Category
Patterns
Added
2026-08-22
Deps
1
Updated
2026-09-01
Preview
01Design

Sketch the shape before you touch a component.

02Build

Wire it up with the real content, not lorem ipsum.

03Ship

Small, reversible, boring deploys beat big risky ones.

Props

0.06
Requires framer-motion
"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>
  );
}
Notes

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).

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

A navbar that slides away on scroll-down and reappears on scroll-up.

navbarscrollheader
New

A pile of cards that fans out symmetrically as its section crosses the viewport.

scrollcardssticky
NewPro

A 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.

navigationscrollsticky