🚀

Update available

We just released a new resource or update, refresh the Vault to access the latest version.

Cancel

Refresh now

Harri

Profile Picture

Harri

Lemke

Stacking Sticky Cards (Bounce)

Documentation

Webflow

Code

Setup: External Scripts

HTML

Copy
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/ScrollTrigger.min.js"></script>

Step 1: Add HTML

HTML

Copy
<!-- 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>

Step 2: Add CSS

CSS

Copy
.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;
  }
}

Step 2: Add Javascript

Step 3: Add Javascript

Javascript

Copy
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;
  }

  // Helper: Get Current Viewport Tier
  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';
  }

  // Helper: Pulse pulse (Bounce Animation)
  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)'
    });
  }
}

// Debouncer: For resizing the window
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);
  };
}

// Initialize Stacking Sticky Cards (Bounce)
document.addEventListener('DOMContentLoaded', function () {
  initStackingStickyCardsBounce();
});

Implementation

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.

This can be useful if you want a solid grid layout on desktop, while turning the same section into a stacking sticky cards experience 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 the rotate values per breakpoint, with the script looping through the list for all cards and falling back to 0, 4, -4 when a breakpoint value is not set.

Card Transform: X

Use [data-stacking-cards-desktop-x], [data-stacking-cards-tablet-x], and [data-stacking-cards-mobile-x] to define the horizontal offset values per breakpoint, with the script looping through the list for all cards and falling back to 0em, 0em, 0em when a breakpoint value is not set.

Card Transform: Y

Use [data-stacking-cards-desktop-y], [data-stacking-cards-tablet-y], and [data-stacking-cards-mobile-y] to define the vertical offset values per breakpoint, with the script looping through the list for all cards and falling back to 0em, 0em, 0em when a breakpoint value is not set.

Card Value Pattern

Use comma-separated values like [data-stacking-cards-desktop-x="0em, 2em, -2em"] so the script can assign one value per card and repeat the pattern when needed.

Bounce Animation

The bounce is applied to the [data-stacking-card-target] element, where the script triggers a quick stretch and elastic return when a card reaches its sticky lock position while scrolling down, creating a subtle feedback as it settles into place.

Responsive Helper Function

A helper function is used to detect viewport changes and rebuild the stacking setup when switching between desktop, tablet, mobile landscape, and mobile portrait, so if you already handle breakpoint-based reinitialization elsewhere this part can be removed.

// Debouncer: For resizing the window
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);
  };
}
Copy

Minimal HTML Structure

When using different settings per breakpoint, a full minimal setup can look like this so the script can correctly read the container, cards, and animated target elements.

<section
  data-stacking-cards-init
  data-stacking-cards-desktop="true"
  data-stacking-cards-tablet="true"
  data-stacking-cards-mobile="true"
  data-stacking-cards-desktop-rotate="0, 4, -4"
  data-stacking-cards-desktop-x="0em, 2em, -2em"
  data-stacking-cards-desktop-y="0em, 0em, 0em"
  data-stacking-cards-tablet-rotate="0, 3, -3"
  data-stacking-cards-tablet-x="0em, 1.5em, -1.5em"
  data-stacking-cards-tablet-y="0em, 0em, 0em"
  data-stacking-cards-mobile-rotate="0, 2, -2"
  data-stacking-cards-mobile-x="0em, 1em, -1em"
  data-stacking-cards-mobile-y="0em, 0em, 0em"
>
  <!-- Card 1 -->
  <div data-stacking-card>
    <div data-stacking-card-target>Card 1</div>
  </div>

  <!-- Card 2 -->
  <div data-stacking-card>
    <div data-stacking-card-target>Card 2</div>
  </div>

  <!-- Card 3 -->
  <div data-stacking-card>
    <div data-stacking-card-target>Card 3</div>
  </div>
  
</section>
Copy

Live preview

Osmo Robot AI

Copy context for AI

Beta

Webflow

HTML/CSS/JS

Save video

Copy share link

Resource details

  • Published

    March 23, 2026

  • Category

    Scroll Animations

  • Popularity

    1.3K visitors

  • Need help?

    Join Slack

Scrolling
Sticky
Card
Bounce
GSAP
Scrolltrigger
Stacked
Animation

Original source

Dennis SnellenbergDennis Snellenberg

Creator Credits

We always strive to credit creators as accurately as possible. While similar concepts might appear online, we aim to provide proper and respectful attribution.

s