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 topand unpinned when the next card arrives at the same coordinate. - Visual tightness: The stack remains compact because
pinSpacing: falseprevents 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:
- Create a client component that accepts an array of cards as
ReactNode[]. - Register GSAP and ScrollTrigger, then instantiate a
gsap.contextinside auseEffect. - Collect card elements using
gsap.utils.toArray(".stack-card"). - Loop over the cards (excluding the last one) and:
- Pin each card with
ScrollTrigger.createusingstart: "top top",end: "top top",pin: true, andpinSpacing: false. - Attach a scale and opacity tween to the current card that is driven by the next card's scroll position.
- Pin each card with
- 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: falseon pinned triggers. Without this, GSAP injects spacer elements that create visual gaps between stacked cards. - Clean up with
ctx.revert(). Thegsap.contextreturned function must be invoked in theuseEffectcleanup phase to destroy all tweens and ScrollTriggers scoped to the wrapper.
Summary
- GSAP sticky-stack patterns with ScrollTrigger are defined in
Leonxlnx/taste-skillatskills/taste-skill/SKILL.mdSection 5.A (lines 665‑724). - Pin each card with
ScrollTrigger.createusingstart: "top top",end: "top top",pin: true, andpinSpacing: false. - Animate the handoff by tweening the current card's
scaleandopacityusing the next card as thescrollTriggertarget. - Guard for reduced motion with
useReducedMotion()frommotion/reactbefore initializing any GSAP side effects. - Scope all animation code inside
gsap.contextand callctx.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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →