scroll·free
Sticky Stack
Cards pin and stack as you scroll; covered cards shrink and dim.
01
Plan
02
Design
03
Animate
04
Ship
Installation
terminal
pnpm add gsap @gsap/reactThen copy the component below into src/components/motion/sticky-stack.tsx. It expects @/lib/gsap (plugin registration) and @/lib/utils (cn).
Source
src/components/motion/sticky-stack.tsx
"use client";
import { Children, useRef, type ReactNode } from "react";
import { gsap, useGSAP } from "@/lib/gsap";
import { cn } from "@/lib/utils";
export interface StickyStackProps {
children: ReactNode;
className?: string;
/** Scale applied to a card as the next one covers it. */
shrink?: number;
/** Top offset in px for the pinned cards. */
top?: number;
}
/** Cards pin at the top and each new card slides over the previous, which shrinks and dims. */
export function StickyStack({ children, className, shrink = 0.92, top = 80 }: StickyStackProps) {
const ref = useRef<HTMLDivElement>(null);
const items = Children.toArray(children);
useGSAP(
() => {
const el = ref.current;
if (!el) return;
const cards = Array.from(el.querySelectorAll<HTMLElement>("[data-stack-card]"));
cards.forEach((card, i) => {
if (i === cards.length - 1) return;
gsap.to(card, {
scale: shrink,
opacity: 0.4,
filter: "blur(4px)",
ease: "none",
scrollTrigger: {
trigger: cards[i + 1],
start: `top ${top + 200}px`,
end: `top ${top}px`,
scrub: true,
},
});
});
},
{ scope: ref, dependencies: [shrink, top] }
);
return (
<div ref={ref} className={cn("relative", className)}>
{items.map((child, i) => (
<div
key={i}
data-stack-card
className="sticky mb-8 origin-top will-change-transform"
style={{ top }}
>
{child}
</div>
))}
</div>
);
}