VeloxUI
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/react

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