Dropping Cards Stack

A stacked card slider with drag-to-dismiss support. Cards animate in and out with a dropping motion. Supports next/prev buttons, keyboard navigation, drag threshold, and automatic card duplication for seamless looping.

gsapdraggablecardsstackslider

Setup — External Scripts

Setup: External Scripts
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/Draggable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script>

Code

index.html
html
<div data-dropping-stack-init="" class="dropping-stack">
  <div data-dropping-stack-collection="" class="dropping-stack__collection">
    <div class="dropping-stack__list">
      <div data-dropping-stack-item="" class="dropping-stack__item">
        <div class="dropping-stack-card">
          <div class="dropping-stack-card__before"></div>
          <div class="dropping-stack-card__content">
            <div class="dropping-stack-card__start">
              <div class="dropping-stack-card__visual">
                <div class="dropping-stack-card__visual-before"></div>
                <img src="https://cdn.prod.website-files.com/6969f795b8c9b9bba545e75b/6969fb1c152133800af9cd81_service-1.avif" loading="lazy" alt="" class="droping-stack-card__visual-img">
              </div>
              <div class="dropping-stack-card__words">
                <p class="dropping-stack-card__p">Brand Strategy</p>
                <p class="dropping-stack-card__p">Logo Design</p>
                <p class="dropping-stack-card__p">Visual Identity</p>
              </div>
            </div>
            <div class="dropping-stack-card__end">
              <h3 class="dropping-stack-card__h">Branding & Identity.</h3>
            </div>
          </div>
        </div>
      </div>
      <div data-dropping-stack-item="" class="dropping-stack__item">
        <div class="dropping-stack-card is--light">
          <div class="dropping-stack-card__before"></div>
          <div class="dropping-stack-card__content">
            <div class="dropping-stack-card__start">
              <div class="dropping-stack-card__visual">
                <div class="dropping-stack-card__visual-before"></div>
                <img src="https://cdn.prod.website-files.com/6969f795b8c9b9bba545e75b/696a00119a186f6eae03811f_service-2.avif" loading="lazy" alt="" class="droping-stack-card__visual-img">
              </div>
              <div class="dropping-stack-card__words">
                <p class="dropping-stack-card__p">Ads Creation</p>
                <p class="dropping-stack-card__p">SEO Setup</p>
                <p class="dropping-stack-card__p">Email Marketing</p>
                <p class="dropping-stack-card__p">Funnel Strategy</p>
                <p class="dropping-stack-card__p">Analytics</p>
              </div>
            </div>
            <div class="dropping-stack-card__end">
              <h3 class="dropping-stack-card__h">Marketing.</h3>
            </div>
          </div>
        </div>
      </div>
      <div data-dropping-stack-item="" class="dropping-stack__item">
        <div class="dropping-stack-card is--purple">
          <div class="dropping-stack-card__before"></div>
          <div class="dropping-stack-card__content">
            <div class="dropping-stack-card__start">
              <div class="dropping-stack-card__visual">
                <div class="dropping-stack-card__visual-before"></div>
                <img src="https://cdn.prod.website-files.com/6969f795b8c9b9bba545e75b/696a000e473a6fb87e025764_service-3.avif" loading="lazy" alt="" class="droping-stack-card__visual-img">
              </div>
              <div class="dropping-stack-card__words">
                <p class="dropping-stack-card__p">UX audits</p>
                <p class="dropping-stack-card__p">Wireframes & Prototypes</p>
                <p class="dropping-stack-card__p">User Testing</p>
              </div>
            </div>
            <div class="dropping-stack-card__end">
              <h3 class="dropping-stack-card__h">UX Strategy.</h3>
            </div>
          </div>
        </div>
      </div>
      <div data-dropping-stack-item="" class="dropping-stack__item">
        <div class="dropping-stack-card is--pink">
          <div class="dropping-stack-card__before"></div>
          <div class="dropping-stack-card__content">
            <div class="dropping-stack-card__start">
              <div class="dropping-stack-card__visual">
                <div class="dropping-stack-card__visual-before"></div>
                <img src="https://cdn.prod.website-files.com/6969f795b8c9b9bba545e75b/696a0012a2bbbee1e9a7b23d_service-4.avif" loading="lazy" alt="" class="droping-stack-card__visual-img">
              </div>
              <div class="dropping-stack-card__words">
                <p class="dropping-stack-card__p">Magic Spells</p>
                <p class="dropping-stack-card__p">Legendary Status</p>
                <p class="dropping-stack-card__p">Creative Powerhouse</p>
                <p class="dropping-stack-card__p">Early Adopter</p>
              </div>
            </div>
            <div class="dropping-stack-card__end">
              <h3 class="dropping-stack-card__h">Osmo Wizard.</h3>
            </div>
          </div>
        </div>
      </div>
      <div data-dropping-stack-item="" class="dropping-stack__item">
        <div class="dropping-stack-card is--dark">
          <div class="dropping-stack-card__before"></div>
          <div class="dropping-stack-card__content">
            <div class="dropping-stack-card__start">
              <div class="dropping-stack-card__visual">
                <div class="dropping-stack-card__visual-before"></div>
                <img src="https://cdn.prod.website-files.com/6969f795b8c9b9bba545e75b/696a034ccd0b1a0c5b3af037_service-5.avif" loading="lazy" alt="" class="droping-stack-card__visual-img">
              </div>
              <div class="dropping-stack-card__words">
                <p class="dropping-stack-card__p">Web Design</p>
                <p class="dropping-stack-card__p">Webflow Development</p>
                <p class="dropping-stack-card__p">Osmo Supply</p>
              </div>
            </div>
            <div class="dropping-stack-card__end">
              <h3 class="dropping-stack-card__h">Websites.</h3>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
  <div class="dropping-stack__controls">
    <div data-dropping-stack-prev="" class="dropping-stack__control is--prev">
      <div class="dropping-stack__control-circle is--prev">
        <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 18 18" fill="none" class="dropping-stack__control-svg"><path d="M6.74976 14.25L11.9998 9L6.74976 3.75" stroke="currentColor" stroke-width="2.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"></path></svg>
      </div>
    </div>
    <div data-dropping-stack-next="" class="dropping-stack__control">
      <div class="dropping-stack__control-circle">
        <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 18 18" fill="none" class="dropping-stack__control-svg"><path d="M6.74976 14.25L11.9998 9L6.74976 3.75" stroke="currentColor" stroke-width="2.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"></path></svg>
      </div>
    </div>
  </div>
</div>
styles.css
css
.dropping-stack {
  grid-column-gap: 2em;
  grid-row-gap: 2em;
  flex-flow: column;
  align-items: center;
  display: flex;
}

.dropping-stack__collection {
  padding-bottom: 7.5em;
  padding-right: 7.5em;
}

.dropping-stack__list {
  justify-content: center;
  align-items: center;
  display: flex;
  position: relative;
}

.dropping-stack__item {
  will-change: transform, opacity;
  -webkit-user-select: none;
  user-select: none;
  position: absolute;
}

.dropping-stack__item:nth-child(1) {
  position: relative;
}

.dropping-stack-card {
  color: #201d1d;
  background-color: #ffc664;
  border-radius: 1.25em;
  width: min(50em, 100vw - 10em);
  position: relative;
  overflow: hidden;
}

.dropping-stack-card.is--light {
  background-color: #f4f4f4;
}

.dropping-stack-card.is--purple {
  color: #f4f4f4;
  background-color: #8963eb;
}

.dropping-stack-card.is--pink {
  background-color: #e4bdf2;
}

.dropping-stack-card.is--dark {
  color: #f4f4f4;
  background-color: #10101f;
}

.dropping-stack-card__before {
  padding-top: 62.5%;
}

.dropping-stack-card__content {
  flex-flow: column;
  justify-content: space-between;
  width: 100%;
  height: 100%;
  padding: 3em;
  display: flex;
  position: absolute;
  top: 0;
  left: 0;
}

.dropping-stack-card__start {
  justify-content: space-between;
  display: flex;
}

.dropping-stack-card__visual {
  background-color: #0000001a;
  border-radius: .5em;
  width: 35%;
  position: relative;
}

.dropping-stack-card__visual-before {
  padding-top: 62.5%;
}

.droping-stack-card__visual-img {
  object-fit: cover;
  border-radius: inherit;
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

.dropping-stack-card__words {
  width: 45%;
}

.dropping-stack-card__end {
  display: flex;
}

.dropping-stack-card__h {
  letter-spacing: -.03em;
  margin-top: 0;
  margin-bottom: 0;
  font-size: 4.75em;
  font-weight: 600;
  line-height: .9;
}

.dropping-stack__controls {
  grid-column-gap: .375em;
  grid-row-gap: .375em;
  display: flex;
}

.dropping-stack__control {
  cursor: pointer;
  border-radius: 50%;
}

.dropping-stack__control-circle {
  color: #201d1d;
  background-color: #f4f4f4;
  border-radius: 50%;
  flex: none;
  justify-content: center;
  align-items: center;
  width: 3em;
  height: 3em;
  display: flex;
  position: relative;
  transition: transform 0.3s ease;
  transform: translateY(0em) rotate(0.001deg);
}

.dropping-stack__control:hover .dropping-stack__control-circle {
  transform: translateY(-0.25em) rotate(0.001deg);
}

.dropping-stack__control-circle.is--prev {
  color: #f4f4f4;
  background-color: #f4f4f433;
}

.dropping-stack__control-svg {
  width: 40%;
}

.dropping-stack__control.is--prev {
  transform: scaleX(-1);
}

@media screen and (max-width: 991px) {
  .dropping-stack-card__before {
    padding-top: 75%;
  }
}

@media screen and (max-width: 767px) {
  .dropping-stack__collection {
    padding-bottom: 4.5em;
    padding-right: 4.5em;
  }

  .dropping-stack-card {
    width: min(50em, 100vw - 6.5em);
  }

  .dropping-stack-card__before {
    padding-top: 150%;
  }

  .dropping-stack-card__content {
    padding: 7.5vw;
  }

  .dropping-stack-card__start {
    grid-column-gap: 2em;
    grid-row-gap: 2em;
    flex-flow: column;
  }

  .dropping-stack-card__h {
    font-size: 10vw;
  }

  .dropping-stack-card__visual {
    width: 60%;
  }

  .dropping-stack-card__words {
    width: 100%;
  }
}
script.js
javascript
gsap.registerPlugin(Draggable, CustomEase);
CustomEase.create("osmo", "0.625, 0.05, 0, 1");

function initDroppingCardsStack() {
  const stacks = document.querySelectorAll('[data-dropping-stack-init]');
  if (!stacks.length) return;

  // Settings
  const visibleCount        = 4;
  const minTotalForLoop     = 5;
  const duration            = 0.75;
  const mainEase            = "osmo";
  const dragThresholdPercent = 20;

  const getUnitValue = (val, depth) => {
    const num  = parseFloat(val) || 0;
    const unit = String(val).replace(/[0-9.-]/g, '') || 'px';
    return (num * depth) + unit;
  };

  stacks.forEach((stackEl) => {
    const nextBtn = stackEl.querySelector('[data-dropping-stack-next]');
    const prevBtn = stackEl.querySelector('[data-dropping-stack-prev]');
    const list    = stackEl.querySelector('.dropping-stack__list');
    let cards     = Array.from(list.querySelectorAll('[data-dropping-stack-item]'));
    if (cards.length < 3) return;

    const originalCount = cards.length;
    if (cards.length < minTotalForLoop) {
      const setsNeeded  = Math.ceil(minTotalForLoop / originalCount);
      const clonesToAdd = (setsNeeded * originalCount) - originalCount;

      for (let i = 0; i < clonesToAdd; i++) {
        const clone = cards[i % originalCount].cloneNode(true);
        clone.setAttribute('aria-hidden', 'true');
        list.appendChild(clone);
      }

      cards = Array.from(list.querySelectorAll('[data-dropping-stack-item]'));
    }

    const total = cards.length;
    let activeIndex = 0;
    let isAnimating = false;

    let dragCard          = null;
    let draggableInstance = null;
    let limitX = 1;
    let limitY = 1;
    let offsetX = "0em";
    let offsetY = "0em";
    let isActive = false;

    const mod    = (n, m) => ((n % m) + m) % m;
    const cardAt = (offset) => cards[mod(activeIndex + offset, total)];

    function updateOffsetsFromPadding() {
      const collectionEl = stackEl.querySelector('[data-dropping-stack-collection]');
      if (!collectionEl) return;

      const styles  = getComputedStyle(collectionEl);
      const padRight  = parseFloat(styles.paddingRight)  || 0;
      const padLeft   = parseFloat(styles.paddingLeft)   || 0;
      const padBottom = parseFloat(styles.paddingBottom) || 0;
      const padTop    = parseFloat(styles.paddingTop)    || 0;

      const steps  = Math.max(1, visibleCount - 1);
      const usePadX = Math.max(padRight, padLeft);
      const usePadY = Math.max(padBottom, padTop);
      const signX   = padLeft > padRight  ? -1 : 1;
      const signY   = padTop  > padBottom ? -1 : 1;

      offsetX = ((usePadX / steps) * signX) + "px";
      offsetY = ((usePadY / steps) * signY) + "px";
    }

    function updateDragLimits() {
      if (!dragCard) return;
      const rect = dragCard.getBoundingClientRect();
      limitX = rect.width  || 1;
      limitY = rect.height || 1;
    }

    function applyState() {
      updateOffsetsFromPadding();

      cards.forEach((card) => {
        gsap.set(card, { opacity: 0, pointerEvents: 'none', zIndex: 0, x: 0, y: 0, xPercent: 0, yPercent: 0 });
      });

      for (let depth = 0; depth < visibleCount; depth++) {
        const card = cardAt(depth);
        const xVal = getUnitValue(offsetX, depth);
        const yVal = getUnitValue(offsetY, depth);

        const state = {
          opacity: 1,
          zIndex: 999 - depth,
          pointerEvents: depth === 0 ? 'auto' : 'none',
        };

        if (offsetX.includes('%')) state.xPercent = parseFloat(xVal); else state.x = xVal;
        if (offsetY.includes('%')) state.yPercent = parseFloat(yVal); else state.y = yVal;

        gsap.set(card, state);
      }

      dragCard = cardAt(0);
      gsap.set(dragCard, { touchAction: 'none' });
      updateDragLimits();

      if (draggableInstance) { draggableInstance.kill(); draggableInstance = null; }

      const magnetize = (raw, limit) => {
        const sign = Math.sign(raw) || 1;
        const abs  = Math.abs(raw);
        return sign * (limit * Math.tanh(abs / limit));
      };

      draggableInstance = Draggable.create(dragCard, {
        type: 'x,y',
        inertia: false,
        onPress: function () {
          if (isAnimating) return;
          gsap.killTweensOf(dragCard);
          gsap.set(dragCard, { zIndex: 2000, opacity: 1 });
        },
        onDrag: function () {
          if (isAnimating) return;
          gsap.set(dragCard, {
            x: magnetize(this.x, limitX),
            y: magnetize(this.y, limitY),
            opacity: 1,
          });
        },
        onRelease: function () {
          if (isAnimating) return;
          const currentX = gsap.getProperty(dragCard, 'x');
          const currentY = gsap.getProperty(dragCard, 'y');
          const movedPercent = Math.max(
            Math.abs(currentX) / limitX * 100,
            Math.abs(currentY) / limitY * 100
          );

          if (movedPercent >= dragThresholdPercent) {
            animateNext(true, currentX, currentY);
            return;
          }

          gsap.to(dragCard, {
            x: 0, y: 0, opacity: 1,
            duration: 1,
            ease: 'elastic.out(1, 0.7)',
            onComplete: () => { applyState(); }
          });
        },
      })[0];
    }

    function animateNext(fromDrag = false, releaseX = 0, releaseY = 0) {
      if (isAnimating) return;
      isAnimating = true;

      const outgoing    = cardAt(0);
      const incomingBack = cardAt(visibleCount);

      const tl = gsap.timeline({
        defaults: { duration, ease: mainEase },
        onComplete: () => {
          activeIndex = mod(activeIndex + 1, total);
          applyState();
          isAnimating = false;
        },
      });

      gsap.set(outgoing, { zIndex: 2000, opacity: 1 });
      if (fromDrag) gsap.set(outgoing, { x: releaseX, y: releaseY });

      tl.to(outgoing, { yPercent: 200 }, 0);
      tl.to(outgoing, { opacity: 0, duration: duration * 0.2, ease: 'none' }, duration * 0.4);

      for (let depth = 1; depth < visibleCount; depth++) {
        const xVal = getUnitValue(offsetX, depth - 1);
        const yVal = getUnitValue(offsetY, depth - 1);
        const move = { zIndex: 999 - (depth - 1) };
        if (offsetX.includes('%')) move.xPercent = parseFloat(xVal); else move.x = xVal;
        if (offsetY.includes('%')) move.yPercent = parseFloat(yVal); else move.y = yVal;
        tl.to(cardAt(depth), move, 0);
      }

      const backX   = getUnitValue(offsetX, visibleCount);
      const backY   = getUnitValue(offsetY, visibleCount);
      const startX  = getUnitValue(offsetX, visibleCount - 1);
      const startY  = getUnitValue(offsetY, visibleCount - 1);

      const incomingSet = { opacity: 0, zIndex: 999 - visibleCount };
      if (offsetX.includes('%')) incomingSet.xPercent = parseFloat(backX); else incomingSet.x = backX;
      if (offsetY.includes('%')) incomingSet.yPercent = parseFloat(backY); else incomingSet.y = backY;
      gsap.set(incomingBack, incomingSet);

      const incomingTo = { opacity: 1 };
      if (offsetX.includes('%')) incomingTo.xPercent = parseFloat(startX); else incomingTo.x = startX;
      if (offsetY.includes('%')) incomingTo.yPercent = parseFloat(startY); else incomingTo.y = startY;
      tl.to(incomingBack, incomingTo, 0);
    }

    function animatePrev() {
      if (isAnimating) return;
      isAnimating = true;

      const incomingTop = cardAt(-1);
      const leavingBack = cardAt(visibleCount - 1);

      const tl = gsap.timeline({
        defaults: { duration, ease: mainEase },
        onComplete: () => {
          activeIndex = mod(activeIndex - 1, total);
          applyState();
          isAnimating = false;
        },
      });

      gsap.set(leavingBack, { zIndex: 1 });
      gsap.set(incomingTop, { opacity: 0, x: 0, xPercent: 0, yPercent: -200, zIndex: 2000 });
      tl.to(incomingTop, { yPercent: 0 }, 0);
      tl.to(incomingTop, { opacity: 1, duration: duration * 0.2, ease: 'none' }, duration * 0.3);

      for (let depth = 0; depth < visibleCount - 1; depth++) {
        const xVal = getUnitValue(offsetX, depth + 1);
        const yVal = getUnitValue(offsetY, depth + 1);
        const move = { zIndex: 999 - (depth + 1) };
        if (offsetX.includes('%')) move.xPercent = parseFloat(xVal); else move.x = xVal;
        if (offsetY.includes('%')) move.yPercent = parseFloat(yVal); else move.y = yVal;
        tl.to(cardAt(depth), move, 0);
      }

      const backX   = getUnitValue(offsetX, visibleCount);
      const backY   = getUnitValue(offsetY, visibleCount);
      const hideBack = { opacity: 0 };
      if (offsetX.includes('%')) hideBack.xPercent = parseFloat(backX); else hideBack.x = backX;
      if (offsetY.includes('%')) hideBack.yPercent = parseFloat(backY); else hideBack.y = backY;
      tl.to(leavingBack, hideBack, 0);
    }

    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        isActive = entry.isIntersecting && entry.intersectionRatio >= 0.6;
      });
    }, { threshold: [0, 0.6, 1] });
    observer.observe(stackEl);

    window.addEventListener('keydown', (e) => {
      if (!isActive || isAnimating) return;
      const tag      = e.target?.tagName?.toLowerCase() ?? '';
      const isTyping = tag === 'input' || tag === 'textarea' || tag === 'select' || e.target?.isContentEditable;
      if (isTyping) return;
      if (e.key === 'ArrowRight') { e.preventDefault(); animateNext(false); }
      if (e.key === 'ArrowLeft')  { e.preventDefault(); animatePrev(); }
    });

    applyState();

    if (nextBtn) nextBtn.addEventListener('click', () => animateNext(false));
    if (prevBtn) prevBtn.addEventListener('click', animatePrev);

    window.addEventListener('resize', () => { applyState(); });
  });
}

// Initialize Dropping Cards Stack
document.addEventListener('DOMContentLoaded', function() {
  initDroppingCardsStack();
});

Attributes

NameTypeDefaultDescription
data-dropping-stack-initbooleanMarks the root element for one stack instance. Multiple instances on the same page are supported.
data-dropping-stack-collectionbooleanThe script reads the CSS padding of this element to calculate the per-card offset that creates the stacked look.
data-dropping-stack-itembooleanMarks each card element in the stack. Must be a direct child of the list wrapper.
data-dropping-stack-nextbooleanAdd to any clickable element to trigger a forward rotation — the top card drops out and the next card enters.
data-dropping-stack-prevbooleanAdd to any clickable element to trigger a backward rotation — the previous card returns to the top.

Notes

  • GSAP, Draggable, and CustomEase must all be loaded before the script runs.
  • Draggable is a GSAP Club plugin — it requires a GSAP Club or Business membership.
  • The minimum number of cards for seamless looping is 5. If fewer are provided, the script automatically clones full sets until the minimum is reached.
  • The stacked offset is derived from the padding of [data-dropping-stack-collection]. Change padding-right and padding-bottom (or left/top) in CSS to adjust card spacing.
  • Keyboard arrow navigation is only active when the stack is at least 60% in the viewport, and is ignored when focus is inside a form field.
  • Drag threshold, visible card count, duration, and easing are all configurable at the top of the script.

Guide

Stack

Use [data-dropping-stack-init] to define the root element. The script queries all matching elements and initialises each one independently.

<div data-dropping-stack-init class="dropping-stack">
  <div data-dropping-stack-collection class="dropping-stack__collection">
    <div class="dropping-stack__list">
      <div data-dropping-stack-item class="dropping-stack__item"></div>
      <div data-dropping-stack-item class="dropping-stack__item"></div>
      <div data-dropping-stack-item class="dropping-stack__item"></div>
    </div>
  </div>
  <button type="button" data-dropping-stack-prev>Prev</button>
  <button type="button" data-dropping-stack-next>Next</button>
</div>

Cards offset

The script reads the computed padding of [data-dropping-stack-collection] and divides it evenly across the visible cards. For example, padding-right: 6em and padding-bottom: 6em with 4 visible cards = 2em offset per card. Change the padding values in CSS to control how spread out the stack looks.

Next & Prev

Use [data-dropping-stack-next] to drop the top card and advance, and [data-dropping-stack-prev] to return the previous card to the top. Arrow keys work when the stack is in view.

Drag to dismiss

The top card is draggable. If the user drags it past the threshold (default 20% of card size), the next animation triggers. Releasing below the threshold snaps the card back with an elastic animation.

Customising settings

Edit the settings block at the top of the script to control visible card count, looping minimum, animation duration, easing, and drag threshold.

const visibleCount         = 4;
const minTotalForLoop      = 5;
const duration             = 0.75;
const mainEase             = "osmo";
const dragThresholdPercent = 20;