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.

gsapscrolltriggerstickycardsscrollbounce

Setup — External Scripts

Setup: External Scripts
html
<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

index.html
html
<!-- 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>
styles.css
css
.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;
  }
}
script.js
javascript
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.