
Cards that stack on scroll: the events animation for One Ocean Foundation
In May 2026 I added a section dedicated to events to the One Ocean Foundation homepage. The idea is easy to state: instead of a grid, cards arrive one at a time as you scroll, and the ones already seen stay underneath, slightly smaller, like a deck stacking up.
In October the homepage was redesigned and the section is gone, but the technique is reusable and the problems I ran into are the typical ones of scroll-driven animation.
How it works
The section stays pinned while the user scrolls, for a length proportional to the number of cards. A scroll-linked GSAP timeline brings each card up from the bottom to its place and meanwhile shrinks the previous ones: each loses a tenth of its scale for every card that passes over it, down to a minimum.
const tl = gsap.timeline({
scrollTrigger: {
trigger: section,
start: "bottom bottom",
end: `+=${cards.length * 700}`,
pin: true,
scrub: true,
invalidateOnRefresh: true,
},
});
cards.forEach((card, i) => {
tl.fromTo(card, { yPercent: i === 0 ? 0 : 100 }, { yPercent: -i * 100 });
// Cards already seen shrink a little for every card that lands on top.
for (let j = 0; j < i; j++) {
tl.to(cards[j], { scale: Math.max(1 - (i - j) * 0.1, 0.6) }, "<");
}
});Card colours alternated across three shades of the foundation's palette, so each event stood apart from the one below even while moving.
Pin order matters
The same page had other animated sections with a pin, and ScrollTrigger computes positions in order. If a pinned section further down is measured before the events one, its coordinates start off wrong. I gave the events pin a higher refresh priority, so it is computed first, and made the timelines recompute when the window size changes.
Mobile without jumps
The next section had a different animation on desktop and mobile. On first render the page did not yet know whether it was on mobile, so it applied the desktop setup and then corrected it: a visible jump. I moved that choice into gsap.matchMedia, which decides from the media query without waiting for React state.
In the same way, elements that started hidden to be animated now stay hidden only on desktop: on mobile, where there is no animation, they show straight away.
One last fix: when leaving the page, the pin was not always removed and React tried to unmount a node GSAP had already moved. Since then the pin is killed explicitly on unmount.
What I take from this project
- A scroll animation has to be measured like a layout: pin order, recompute on resize, mobile handled separately.
- The mobile setup should be decided by media queries, not by state that arrives after the first render.
- Elements hidden in order to animate them must stay visible where there is no animation.
The rest of the site is in the One Ocean Foundation case study.


