Stacking Sticky Cards (Bounce)
A sticky card stack effect using GSAP ScrollTrigger where each card rotates, offsets, and bounces as it reaches its sticky lock position on scroll. Supports custom rotate, x, and y values per breakpoint with two variants: a three-card row and wide full-width cards.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>Code
<!-- Variant 1: Three cards in a row -->
<section data-stacking-cards-init="" data-stacking-cards-desktop="true" data-stacking-cards-tablet="true" data-stacking-cards-mobile="true" data-stacking-cards-desktop-x="-13.75em, 0em, 13em" data-stacking-cards-desktop-y="2.125em, 0em, 4.5em" data-stacking-cards-desktop-rotate="-5, 2, 6" class="cards-stack">
<div class="container">
<div class="cards-stack__collection">
<div data-stacking-card-stack="" class="cards-stack__list">
<div data-slot-parent="" data-stacking-card="" class="cards-stack__item">
<div data-stacking-card-target="" class="cards-stack-card">
<div class="cards-stack-card__start">
<span class="cards-stack-card__number">1.</span>
</div>
<div class="cards-stack-card__end">
<h3 class="cards-stack-card__h">Marketing</h3>
<div class="cards-stack-card__services">
<p class="cards-stack-card__services-p">Ads Creation</p>
<p class="cards-stack-card__services-p">SEO Setup</p>
<p class="cards-stack-card__services-p">Email Marketing</p>
<p class="cards-stack-card__services-p">Funnel Strategy</p>
<p class="cards-stack-card__services-p">Analytics</p>
</div>
</div>
</div>
</div>
<div data-slot-parent="" data-stacking-card="" class="cards-stack__item">
<div data-stacking-card-target="" class="cards-stack-card is--green">
<div class="cards-stack-card__start">
<span class="cards-stack-card__number">2.</span>
</div>
<div class="cards-stack-card__end">
<h3 class="cards-stack-card__h">Branding & Identity</h3>
<div class="cards-stack-card__services">
<p class="cards-stack-card__services-p">Brand Strategy</p>
<p class="cards-stack-card__services-p">Logo Design</p>
<p class="cards-stack-card__services-p">Visual Identity</p>
</div>
</div>
</div>
</div>
<div data-slot-parent="" data-stacking-card="" class="cards-stack__item">
<div data-stacking-card-target="" class="cards-stack-card is--dark">
<div class="cards-stack-card__start">
<span class="cards-stack-card__number">3.</span>
</div>
<div class="cards-stack-card__end">
<h3 class="cards-stack-card__h">UX Strategy</h3>
<div class="cards-stack-card__services">
<p class="cards-stack-card__services-p">UX audits</p>
<p class="cards-stack-card__services-p">Wireframes & Prototypes</p>
<p class="cards-stack-card__services-p">User Testing</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Variant 2: Wide cards -->
<section data-stacking-cards-init="" data-stacking-cards-desktop="true" data-stacking-cards-tablet="true" data-stacking-cards-mobile="true" class="cards-stack">
<div class="container">
<div class="cards-stack__collection">
<div data-stacking-card-stack="" class="cards-stack__list">
<div data-stacking-card="" class="cards-stack__item is--wide">
<div data-stacking-card-target="" class="cards-stack-card is--wide">
<div class="cards-stack-card__start">
<span class="cards-stack-card__number">1.</span>
</div>
<div class="cards-stack-card__end">
<h3 class="cards-stack-card__h is--l">Marketing</h3>
<div class="cards-stack-card__services">
<p class="cards-stack-card__services-p">Ads Creation</p>
<p class="cards-stack-card__services-p">SEO Setup</p>
<p class="cards-stack-card__services-p">Email Marketing</p>
<p class="cards-stack-card__services-p">Funnel Strategy</p>
<p class="cards-stack-card__services-p">Analytics</p>
</div>
</div>
</div>
</div>
<div data-stacking-card="" class="cards-stack__item is--wide">
<div data-stacking-card-target="" class="cards-stack-card is--wide is--green">
<div class="cards-stack-card__start">
<span class="cards-stack-card__number">2.</span>
</div>
<div class="cards-stack-card__end">
<h3 class="cards-stack-card__h is--l">Branding & Identity</h3>
<div class="cards-stack-card__services">
<p class="cards-stack-card__services-p">Brand Strategy</p>
<p class="cards-stack-card__services-p">Logo Design</p>
<p class="cards-stack-card__services-p">Visual Identity</p>
</div>
</div>
</div>
</div>
<div data-stacking-card="" class="cards-stack__item is--wide">
<div data-stacking-card-target="" class="cards-stack-card is--wide is--dark">
<div class="cards-stack-card__start">
<span class="cards-stack-card__number">3.</span>
</div>
<div class="cards-stack-card__end">
<h3 class="cards-stack-card__h is--l">UX Strategy</h3>
<div class="cards-stack-card__services">
<p class="cards-stack-card__services-p">UX audits</p>
<p class="cards-stack-card__services-p">Wireframes & Prototypes</p>
<p class="cards-stack-card__services-p">User Testing</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>.cards-stack {
padding-top: 15dvh;
padding-bottom: 15dvh;
}
.container {
max-width: 90em;
margin-left: auto;
margin-right: auto;
padding-left: 2em;
padding-right: 2em;
}
.cards-stack__list {
grid-column-gap: 5em;
grid-row-gap: 5em;
flex-flow: column;
justify-content: center;
align-items: center;
width: 100%;
display: flex;
}
.cards-stack__item {
flex: none;
width: 100%;
max-width: 25em;
position: sticky;
top: 5em;
}
.cards-stack__item.is--wide {
max-width: 60em;
}
.cards-stack-card {
aspect-ratio: 2 / 3;
background-color: #fff;
border-radius: 2em;
flex-flow: column;
justify-content: space-between;
width: 100%;
padding: 2.5em;
display: flex;
}
.cards-stack-card.is--green {
background-color: #b1ae91;
}
.cards-stack-card.is--dark {
color: #fff;
background-color: #201d1d;
}
.cards-stack-card.is--wide {
aspect-ratio: 5 / 3;
}
.cards-stack-card__number {
font-size: 6.75em;
font-weight: 500;
line-height: .95;
}
.cards-stack-card__h {
letter-spacing: -.04em;
margin-top: 0;
margin-bottom: 0;
font-size: 3.375em;
font-weight: 600;
line-height: .95;
}
.cards-stack-card__h.is--wide {
font-size: 4.5em;
}
.cards-stack-card__services {
flex-flow: column;
justify-content: flex-end;
min-height: 11em;
display: flex;
}
.cards-stack-card__services-p {
letter-spacing: -.01em;
margin-bottom: 0;
font-size: 1.125em;
font-weight: 500;
line-height: 1.4;
}
@media screen and (max-width: 991px) {
.cards-stack-card.is--wide {
aspect-ratio: 5 / 4;
}
}
@media screen and (max-width: 767px) {
.cards-stack__item.is--wide {
max-width: 25em;
}
.cards-stack-card {
font-size: .8em;
}
.cards-stack-card.is--wide {
aspect-ratio: 2 / 3;
}
.cards-stack-card__h.is--wide {
font-size: 3.375em;
}
}gsap.registerPlugin(ScrollTrigger);
function initStackingStickyCardsBounce() {
const cardsSections = document.querySelectorAll('[data-stacking-cards-init]');
const currentTier = getCurrentViewportTier();
window.viewportTier = currentTier;
ScrollTrigger.getAll().forEach((trigger) => {
cardsSections.forEach((section) => {
if (section.contains(trigger.trigger)) trigger.kill();
});
});
cardsSections.forEach((section) => {
section.querySelectorAll('[data-stacking-card-target]').forEach((el) => {
gsap.killTweensOf(el);
gsap.set(el, { clearProps: 'all' });
});
});
cardsSections.forEach((section) => {
const tier = currentTier;
const isEnabled = (tier === 'desktop' && section.dataset.stackingCardsDesktop === 'true') ||
(tier === 'tablet' && section.dataset.stackingCardsTablet === 'true') ||
((tier === 'mobile-portrait' || tier === 'mobile-landscape') &&
section.dataset.stackingCardsMobile === 'true'
);
if (!isEnabled) return;
const cards = Array.from(section.querySelectorAll('[data-stacking-card]'));
if (!cards.length) return;
const stickyTop = parseFloat(getComputedStyle(cards[0]).top) || 0;
const rotateValues = (() => {
if (tier === 'desktop') return parseRotateValues(section, 'data-stacking-cards-desktop-rotate');
if (tier === 'tablet') return parseRotateValues(section, 'data-stacking-cards-tablet-rotate');
return parseRotateValues(section, 'data-stacking-cards-mobile-rotate');
})();
const xValues = (() => {
if (tier === 'desktop') return parseAxisValues(section, 'data-stacking-cards-desktop-x');
if (tier === 'tablet') return parseAxisValues(section, 'data-stacking-cards-tablet-x');
return parseAxisValues(section, 'data-stacking-cards-mobile-x');
})();
const yValues = (() => {
if (tier === 'desktop') return parseAxisValues(section, 'data-stacking-cards-desktop-y');
if (tier === 'tablet') return parseAxisValues(section, 'data-stacking-cards-tablet-y');
return parseAxisValues(section, 'data-stacking-cards-mobile-y');
})();
cards.forEach((card, index) => {
const targetEl = card.querySelector('[data-stacking-card-target]');
if (!targetEl) return;
const rotate = rotateValues[index % rotateValues.length];
const x = xValues[index % xValues.length];
const y = yValues[index % yValues.length];
gsap.set(targetEl, {
rotate: 0,
x: 0,
y: 0,
scale: 1,
zIndex: cards.length - index
});
gsap.to(targetEl, {
rotate,
x,
y,
ease: 'power1.in',
overwrite: 'auto',
scrollTrigger: {
id: `stacking-rotate-${index}`,
trigger: card,
start: 'top 75%',
end: `top-=${stickyTop} top`,
scrub: true
}
});
ScrollTrigger.create({
id: `stacking-bounce-${index}`,
trigger: card,
start: `top-=${stickyTop} top`,
onEnter: () => pulseElement(targetEl)
});
});
});
ScrollTrigger.refresh();
function parseRotateValues(section, attr) {
const fallback = [0, 4, -4];
const values = (section.getAttribute(attr) || '').split(',').map((val) => parseFloat(val.trim()));
return values.length >= 1 && values.every((v) => !isNaN(v)) ? values : fallback;
}
function parseAxisValues(section, attr) {
const raw = section.getAttribute(attr);
if (!raw) return ['0em', '0em', '0em'];
const values = raw.split(',').map((val) => val.trim()).filter((val) => val !== '');
return values.length ? values : ['0em', '0em', '0em'];
}
if (!window._hasStackingResizeListener) {
let last = getCurrentViewportTier();
window.addEventListener('resize', debounceOnWidthChange(() => {
const next = getCurrentViewportTier();
if (last !== next) {
ScrollTrigger.getAll().forEach((t) => {
if (t.vars?.id?.startsWith('stacking')) t.kill();
});
cardsSections.forEach((section) => {
section.querySelectorAll('[data-stacking-card-target]').forEach((el) => {
gsap.killTweensOf(el);
gsap.set(el, { clearProps: 'all' });
});
});
initStackingStickyCardsBounce();
}
last = next;
window.viewportTier = next;
}, 250));
window._hasStackingResizeListener = true;
}
function getCurrentViewportTier() {
const width = window.innerWidth;
if (width <= 479) return 'mobile-portrait';
if (width <= 767) return 'mobile-landscape';
if (width <= 991) return 'tablet';
return 'desktop';
}
function pulseElement(targetEl) {
const width = targetEl.offsetWidth;
const height = targetEl.offsetHeight;
const fontSize = parseFloat(getComputedStyle(targetEl).fontSize);
const stretchPx = 1.5 * fontSize;
const targetScaleX = (width + stretchPx) / width;
const targetScaleY = (height - stretchPx * 0.33) / height;
const tl = gsap.timeline();
tl.to(targetEl, {
scaleX: targetScaleX,
scaleY: targetScaleY,
duration: 0.1,
ease: 'power1.out'
}).to(targetEl, {
scaleX: 1,
scaleY: 1,
duration: 1,
ease: 'elastic.out(1, 0.3)'
});
}
}
function debounceOnWidthChange(fn, ms) {
let last = innerWidth;
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
if (innerWidth !== last) {
last = innerWidth;
fn.apply(this, args);
}
}, ms);
};
}
document.addEventListener('DOMContentLoaded', function () {
initStackingStickyCardsBounce();
});Guide
Container
Use [data-stacking-cards-init] on the parent section that should initialize the stacking sticky cards effect and control all cards inside that block.
Card
Use [data-stacking-card] on each sticky card item that should act as a trigger point for the scroll-based stacking animation and bounce moment.
Target
Use [data-stacking-card-target] on the inner element that should actually receive the rotate, x, y, scale, and bounce animation values.
Breakpoint Toggle
Use [data-stacking-cards-desktop="true"], [data-stacking-cards-tablet="true"], and [data-stacking-cards-mobile="true"] to decide per breakpoint if a section should run the stacking effect or stay inactive. Useful for showing a grid on desktop while enabling stacking on touch devices.
Card Transform: Rotate
Use [data-stacking-cards-desktop-rotate], [data-stacking-cards-tablet-rotate], and [data-stacking-cards-mobile-rotate] to define rotate values per breakpoint. The script loops through the list for all cards and falls back to 0, 4, -4 when not set.
Card Transform: X
Use [data-stacking-cards-desktop-x], [data-stacking-cards-tablet-x], and [data-stacking-cards-mobile-x] to define horizontal offset values per breakpoint. Falls back to 0em, 0em, 0em when not set.
Card Transform: Y
Use [data-stacking-cards-desktop-y], [data-stacking-cards-tablet-y], and [data-stacking-cards-mobile-y] to define vertical offset values per breakpoint. Falls back to 0em, 0em, 0em when not set.
Card Value Pattern
Use comma-separated values like [data-stacking-cards-desktop-x="0em, 2em, -2em"] so the script assigns one value per card and repeats the pattern when there are more cards than values.
Bounce Animation
The bounce is applied to the [data-stacking-card-target] element. The script triggers a quick stretch and elastic return when a card reaches its sticky lock position while scrolling down, creating subtle feedback as it settles into place.
Responsive Helper Function
A helper function detects viewport changes and rebuilds the stacking setup when switching between desktop, tablet, mobile landscape, and mobile portrait. If you already handle breakpoint-based reinitialization elsewhere, this part can be removed.