How to Implement GSAP Sticky-Stack Patterns with ScrollTrigger for Card Animations

GSAP sticky-stack patterns with ScrollTrigger create a card-stack-on-scroll effect by pinning each card at the viewport top with start: "top top" and driving scale and opacity tweens from the next card's scroll position, while pinSpacing: false eliminates gaps between pinned elements.

The GSAP sticky-stack pattern is the canonical method for building vertical carousels of full-page cards that stack on scroll. Defined in the Leonxlnx/taste-skill repository under skills/taste-skill/SKILL.md Section 5.A, this pattern is specifically designed for scroll-stack layouts where precise pinning and smooth handoffs between cards are required. The canonical skeleton and critical implementation rules are detailed at lines 362‑366 and lines 665‑724 of the skill guide.

Why Use the GSAP Sticky-Stack Pattern?

The sticky-stack pattern is purpose-driven motion applied only when a scroll-stack layout is needed, such as a vertical carousel of full-viewport cards. According to the Leonxlnx/taste-skill source code, it solves the common "trigger fires halfway" bug by pinning each card at exactly top top of the viewport. This ensures that a card stays fixed until the next card reaches the same pinning point, creating a seamless physical stack metaphor.

  • Precise pinning: Each card is pinned at top top and unpinned when the next card arrives at the same coordinate.
  • Visual tightness: The stack remains compact because pinSpacing: false prevents ScrollTrigger from injecting extra wrapper spacing.
  • Scalable architecture: The pattern scales to any number of cards while keeping the last card static to avoid unnecessary pinning.

Core Concepts of GSAP Sticky-Stack Patterns with ScrollTrigger

Client Component Requirement

Every GSAP sticky-stack implementation must begin with the "use client" directive because ScrollTrigger relies on browser-side APIs. This directive must be the first line of the component file before any imports.

Plugin Registration

gsap.registerPlugin(ScrollTrigger) must be called once per component file to activate the pinning and scrubbing features globally. Without this registration, ScrollTrigger.create and the scrollTrigger configuration object inside tweens will throw runtime errors.

Scoped Context with gsap.context

All GSAP calls should be wrapped inside gsap.context to enable automatic cleanup. When the React component unmounts, calling ctx.revert() destroys all associated tweens, timelines, and ScrollTrigger instances. This prevents memory leaks and stray listeners that can degrade performance on single-page applications.

Pinning Each Card

ScrollTrigger.create pins the card to the viewport top without leaving residual space. The configuration used in the canonical skeleton is:

ScrollTrigger.create({
  trigger: card,
  start: "top top",
  end: "top top",
  pin: true,
  pinSpacing: false,
});

The start: "top top" value is mandatory according to the taste-skill design guide at lines 362‑366. Using values like "top center" or "top 80%" breaks the stack timing. Setting pinSpacing: false keeps the document flow tight so the next card slides directly over the pinned card.

Scale and Opacity Tween

The previous card shrinks and fades while the next card scrolls into view. This is achieved by attaching a scrollTrigger object to a gsap.to tween on the current card, but using the next card as the trigger:

gsap.to(card, {
  scale: 0.92,
  opacity: 0.55,
  ease: "none",
  scrollTrigger: {
    trigger: nextCard,
    start: "top bottom",
    end: "top top",
    scrub: true,
  },
});

Reduced Motion Fallback

useReducedMotion() from motion/react guards the entire effect. If the user prefers reduced motion, the useEffect returns early and skips all ScrollTrigger creation. This ensures WCAG-compliant accessibility.

Responsive Viewport Height

Each card uses min-h-[100dvh] to guarantee full viewport coverage regardless of mobile UI chrome. This dynamic viewport unit prevents layout shifts during scroll and ensures a smooth vertical-only experience.

Step-by-Step Implementation of GSAP Sticky-Stack Patterns

Follow these steps exactly as laid out in the taste-skill SKILL.md canonical skeleton at lines 665‑724:

  1. Create a client component that accepts an array of cards as ReactNode[].
  2. Register GSAP and ScrollTrigger, then instantiate a gsap.context inside a useEffect.
  3. Collect card elements using gsap.utils.toArray(".stack-card").
  4. Loop over the cards (excluding the last one) and:
    • Pin each card with ScrollTrigger.create using start: "top top", end: "top top", pin: true, and pinSpacing: false.
    • Attach a scale and opacity tween to the current card that is driven by the next card's scroll position.
  5. Render the JSX with each card wrapped in a container using the classes stack-card sticky top-0 min-h-[100dvh] flex items-center justify-center.

Complete React Component Example

The canonical TypeScript skeleton from skills/taste-skill/SKILL.md is implemented as follows:

// StickyStack.tsx
"use client";

import { useRef, useEffect } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useReducedMotion } from "motion/react";

gsap.registerPlugin(ScrollTrigger);

/**
 * Stack of cards that stick to the top of the viewport and shrink/ fade
 * as the next card scrolls into view.
 */
export function StickyStack({
  cards,
}: {
  /** Array of React nodes – each will be rendered as a full‑page card */
  cards: React.ReactNode[];
}) {
  const wrapperRef = useRef<HTMLDivElement>(null);
  const prefersReduced = useReducedMotion();

  useEffect(() => {
    // Respect reduced‑motion users
    if (prefersReduced || !wrapperRef.current) return;

    const ctx = gsap.context(() => {
      // Grab every card element
      const cardEls = gsap.utils.toArray<HTMLElement>(".stack-card");

      cardEls.forEach((card, i) => {
        // The very last card stays static – nothing to pin after it
        if (i === cardEls.length - 1) return;

        // Pin the card to the viewport top
        ScrollTrigger.create({
          trigger: card,
          start: "top top",
          end: "top top",
          pin: true,
          pinSpacing: false,
        });

        // Animate the current card based on the NEXT card's scroll position
        gsap.to(card, {
          scale: 0.92,
          opacity: 0.55,
          ease: "none",
          scrollTrigger: {
            trigger: cardEls[i + 1],
            start: "top bottom",
            end: "top top",
            scrub: true,
          },
        });
      });
    }, wrapperRef); // <- scope all GSAP calls to the wrapper element

    // Clean up on unmount
    return () => ctx.revert();
  }, [prefersReduced]);

  return (
    <div ref={wrapperRef} className="relative">
      {cards.map((card, i) => (
        <div
          key={i}
          className="stack-card sticky top-0 min-h-[100dvh] flex items-center justify-center"
        >
          {card}
        </div>
      ))}
    </div>
  );
}

Usage Example

Import the StickyStack component into any Next.js or React page and pass an array of card nodes:

import { StickyStack } from "@/components/StickyStack";

export default function ExamplePage() {
  const demoCards = [
    <div className="bg-gradient-to-r from-indigo-500 to-purple-600 text-white p-12">
      <h1 className="text-5xl font-bold">Card 1</h1>
      <p>First card content…</p>
    </div>,
    <div className="bg-gray-800 text-white p-12">
      <h1 className="text-5xl font-bold">Card 2</h1>
      <p>Second card content…</p>
    </div>,
    <div className="bg-white text-gray-900 p-12">
      <h1 className="text-5xl font-bold">Card 3</h1>
      <p>Third card content…</p>
    </div>,
  ];

  return <StickyStack cards={demoCards} />;
}

Critical Implementation Details

Several constraints from the taste-skill source code must be followed to avoid broken animations:

  • Always use start: "top top" on the pin trigger. The design guide at lines 362‑366 explicitly warns against alternatives like "top center" or "top 80%" because they cause the pin to activate too late.
  • Pin every card except the last one. The final card has no successor to trigger its unpin, so leaving it static prevents a dangling ScrollTrigger.
  • Set pinSpacing: false on pinned triggers. Without this, GSAP injects spacer elements that create visual gaps between stacked cards.
  • Clean up with ctx.revert(). The gsap.context returned function must be invoked in the useEffect cleanup phase to destroy all tweens and ScrollTriggers scoped to the wrapper.

Summary

  • GSAP sticky-stack patterns with ScrollTrigger are defined in Leonxlnx/taste-skill at skills/taste-skill/SKILL.md Section 5.A (lines 665‑724).
  • Pin each card with ScrollTrigger.create using start: "top top", end: "top top", pin: true, and pinSpacing: false.
  • Animate the handoff by tweening the current card's scale and opacity using the next card as the scrollTrigger target.
  • Guard for reduced motion with useReducedMotion() from motion/react before initializing any GSAP side effects.
  • Scope all animation code inside gsap.context and call ctx.revert() on unmount to prevent memory leaks.
  • Use min-h-[100dvh] on card wrappers to ensure full viewport coverage across all devices.

Frequently Asked Questions

Why must the ScrollTrigger start value be exactly "top top" in a sticky-stack pattern?

According to the taste-skill design guide at lines 362‑366, start: "top top" ensures that the card pins immediately when its top edge hits the viewport top. Using "top center" or "top 80%" delays the pin activation, which breaks the stack timing and causes cards to overlap incorrectly or appear to lag behind the scroll position.

What does pinSpacing: false accomplish in GSAP sticky-stack animations?

pinSpacing: false tells ScrollTrigger not to insert extra spacer elements after a pinned card. In the sticky-stack pattern implemented in skills/taste-skill/SKILL.md, this setting is essential because it keeps the vertical document flow tight. Without it, GSAP would reserve the full height of each pinned card, creating large empty gaps that destroy the layered stack illusion.

How should reduced motion preferences be handled in sticky-stack card animations?

The canonical skeleton uses useReducedMotion() from motion/react at the top of the useEffect. If the hook returns true, the effect returns early and skips all ScrollTrigger.create and gsap.to calls. This approach guarantees that users who prefer reduced motion receive a static, accessible layout instead of potentially disorienting scroll-linked animations.

Why is the last card excluded from pinning in the sticky-stack loop?

The implementation iterates over gsap.utils.toArray(".stack-card") and returns early when i === cardEls.length - 1. Because there is no subsequent card to trigger the unpin or drive the scale tween, pinning the final card would leave an unnecessary and confusing ScrollTrigger active. The last card naturally scrolls into view and rests at the end of the stack.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →