GSAP & Scroll Motion
Sticky Stacked Testimonial Cards on Scroll
Cards pin with CSS position: sticky and each one scales back as the next slides over it, so the stack is native scroll plus a scrubbed transform: no pinning spacers, no layout shift.
Works in Webflow Framer React Zero CLS
Customize before you copy
Showing the Webflow version
You are a senior creative developer. Build a "sticky stacked cards" testimonial section.
Behaviour:
- 4–6 testimonial cards in a vertical list. Each card sticks to the viewport with CSS position: sticky; card i sticks at top: calc(12vh + i * 24px), so earlier cards peek out above later ones like a deck.
- As card i+1 scrolls up over card i, scrub card i down to scale 0.92 and opacity 0.6 (transform-origin: top center). The last card never shrinks.
- The scrub runs from the moment the NEXT card enters the viewport until it reaches its own sticky offset, with 0.8s of smoothing.
Hard constraints:
- Animate transform and opacity only. Never animate top, height, margin or filter.
- Do NOT pin with JavaScript: sticky positioning does the pinning, so there are no pin-spacers and zero CLS. No ancestor of the cards may have overflow: hidden (it silently breaks sticky).
- Reduced-motion users get the plain sticky stack with no scrub.
- Add will-change: transform only while a card is animating, never permanently.
- Semantic markup: a <section> with an aria-labelledby heading, each card a <figure> with <blockquote> and <figcaption>.
Deliver the structure, the CSS and the script with a cleanup, then tell me which three values to tweak for a tighter or looser deck.
Target platform: Webflow.
- Build the structure as native Webflow Designer elements with Client-First class names (section_[name], [name]_component, [name]_item). No div soup from an HTML embed unless I ask for one.
- Hook every behaviour to custom attributes (data-gsap="…"), never to class names, so designers can restyle without breaking the script.
- Load GSAP through Webflow's built-in GSAP integration (Site settings) or the jsDelivr CDN tags, and put the init script in Page settings → Before </body> tag, wrapped in Webflow.push(() => { … }).
- Say which parts go in Designer styles, which in a page-head <style>, and which in the body script.
- Designer structure: section_testimonials > testimonials_list (data-gsap="stack-list") > testimonials_card (data-gsap="stack-card") ×4–6. The script sets each card's --i, so editors can add or remove cards freely (a CMS list works too).
Motion timing: "Editorial". Use a base duration of 0.8s, ease "expo.out" (CSS: cubic-bezier(0.16, 1, 0.3, 1)), stagger 0.08s, scrub smoothing 0.8, and springs of stiffness 220 / damping 22, everywhere a duration, ease or spring appears.<!-- Designer: section_testimonials > testimonials_list [data-gsap="stack-list"]
> testimonials_card [data-gsap="stack-card"] ×4–6 (static or a CMS list) -->
<!-- Page settings > Inside <head> -->
<style>
[data-gsap="stack-list"] { display: grid; gap: 40vh; padding-bottom: 20vh; }
[data-gsap="stack-card"] {
position: sticky;
top: calc(12vh + var(--i, 0) * 24px);
transform-origin: top center;
}
</style>
<!-- Page settings > Before </body> tag -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script>
window.Webflow ||= [];
window.Webflow.push(() => {
gsap.registerPlugin(ScrollTrigger);
const cards = gsap.utils.toArray('[data-gsap="stack-card"]');
cards.forEach((card, i) => card.style.setProperty("--i", i));
gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => {
cards.forEach((card, i) => {
const next = cards[i + 1];
if (!next) return;
gsap.to(card, {
scale: 0.92,
opacity: 0.6,
ease: "none",
scrollTrigger: {
trigger: next,
start: "top bottom",
// Stop when the next card lands on its own sticky offset.
end: () => "top " + (innerHeight * 0.12 + (i + 1) * 24) + "px",
scrub: 0.8,
invalidateOnRefresh: true,
onToggle: (self) => gsap.set(card, { willChange: self.isActive ? "transform" : "auto" }),
},
});
});
});
});
</script>3-step implementation guide
Step 1
DOM & attribute setup
Lay the cards out as a vertical list with a large gap (about 40vh) and make each one position: sticky with a top offset that grows by 24px per card. Check that no parent has overflow: hidden, or sticky silently stops working.
Step 2
Prompt Claude or drop in the snippet
Paste the Claude prompt with your platform selected to get a version shaped for your stack, or drop in the snippet as is. The only JavaScript is a scrubbed scale and opacity on the card being covered.
Step 3
Mobile & Core Web Vitals check
On a phone, scroll the whole stack and confirm nothing jumps: CLS should read 0 in PageSpeed Insights. Turn on reduced motion and check the cards still stack, just without the shrink.
Building a full site like this?
Skip the manual setup — I ship complete Figma-to-Webflow & Framer builds in 5–10 business days, or can wire this exact interaction into your live site in 48 hours ($250).