Stacked Cards Slider

A stacked card slider where the top card can be dragged or flicked left/right to cycle through the deck, with alternating rotation, elastic snap-back animations, and an optional shuffle button.

slidercardsstackedgsapdraggableflickelasticshuffletouch

Setup — External Scripts

GSAP 3.13.0
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
Draggable Plugin
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/Draggable.min.js"></script>

Code

index.html
html
<div data-stacked-cards="" class="stack-cards">
  <div class="stacked-cards__stack">
    <div class="stack-cards__before"></div>
    <div class="stacked-cards__collection">
      <div data-stacked-cards-list="" class="stack-cards__list">
        <div data-stacked-cards-item="" class="stack-cards__item">
          <div data-stacked-cards-card="" class="stack-cards__card"><img width="540" loading="lazy" alt="" src="https://cdn.prod.website-files.com/68400ee0c3a4136811a2c90c/6840154ab4df57dbdfedd7df_Pastel%20Green%20Bottle.avif" class="stack-cards__card-image"></div>
        </div>
        <div data-stacked-cards-item="" class="stack-cards__item">
          <div data-stacked-cards-card="" class="stack-cards__card"><img width="540" loading="lazy" alt="" src="https://cdn.prod.website-files.com/68400ee0c3a4136811a2c90c/6840154a04f42a8d992bede9_Soap%20Bar%20in%20Green%20Foam.avif" class="stack-cards__card-image"></div>
        </div>
        <div data-stacked-cards-item="" class="stack-cards__item">
          <div data-stacked-cards-card="" class="stack-cards__card"><img width="540" loading="lazy" alt="" src="https://cdn.prod.website-files.com/68400ee0c3a4136811a2c90c/6840154ac758e72eef2c32d0_Minimalist%20Bottle%20Design.avif" class="stack-cards__card-image"></div>
        </div>
        <div data-stacked-cards-item="" class="stack-cards__item">
          <div data-stacked-cards-card="" class="stack-cards__card"><img width="540" loading="lazy" alt="" src="https://cdn.prod.website-files.com/68400ee0c3a4136811a2c90c/6840154ab342b4ad9b8f9c5e_Luxurious%20Cream%20Jar.avif" class="stack-cards__card-image"></div>
        </div>
        <div data-stacked-cards-item="" class="stack-cards__item">
          <div data-stacked-cards-card="" class="stack-cards__card"><img width="540" loading="lazy" alt="" src="https://cdn.prod.website-files.com/68400ee0c3a4136811a2c90c/6840154af7aed218580948a8_Pastel%20Cosmetic%20Display.avif" class="stack-cards__card-image"></div>
        </div>
        <div data-stacked-cards-item="" class="stack-cards__item">
          <div data-stacked-cards-card="" class="stack-cards__card"><img width="540" loading="lazy" alt="" src="https://cdn.prod.website-files.com/68400ee0c3a4136811a2c90c/6840154ae4b3d097e13b2343_Gray%20Spray%20Bottle%20Display.avif" class="stack-cards__card-image"></div>
        </div>
      </div>
    </div>
  </div>
  <div class="stacked-cards__controls">
    <button data-stacked-cards-control="next" class="shuffle-btn">
      <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="shuffle-btn__icon-svg"><path d="M1 4V10H7" stroke="currentColor" stroke-width="2"></path><path d="M23 20V14H17" stroke="currentColor" stroke-width="2"></path><path d="M20.5 8.99998C19.6855 6.75968 18.0244 4.92842 15.8739 3.89992C13.7235 2.87143 11.2553 2.72782 9 3.49998C7.7459 3.98238 6.59283 4.69457 5.6 5.59998L1 9.99998M23 14L18.4 18.4C16.6963 20.0855 14.3965 21.0308 12 21.0308C9.60347 21.0308 7.30368 20.0855 5.6 18.4C4.69459 17.4072 3.9824 16.2541 3.5 15" stroke="currentColor" stroke-width="2"></path></svg>
      <span class="shuffle-btn__span">Shuffle</span>
    </button>
  </div>
</div>
styles.css
css
.stack-cards {
  width: 25em;
  position: relative;
}

.stacked-cards__stack {
  z-index: 0;
  width: 100%;
  position: relative;
}

.stack-cards__before {
  padding-top: 117.5%;
}

.stacked-cards__collection {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

.stack-cards__list {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

.stack-cards__item {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

.stack-cards__card {
  transition: box-shadow 0.25s cubic-bezier(0.625, 0.05, 0, 1);
  background-color: #fff;
  border: 0.1875em solid #121212;
  border-radius: 1.6em;
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
  overflow: hidden;
  box-shadow: 0em 0.5em 0em 0em rgba(0, 0, 0, 0);
}

.stack-cards__item.is--active .stack-cards__card,
.stack-cards__item.is--second .stack-cards__card {
  box-shadow: 0em 0.5em 0em 0em rgba(0, 0, 0, 0.15);
}

.stack-cards__card-image {
  pointer-events: none;
  object-fit: cover;
  -webkit-user-select: none;
  user-select: none;
  border-radius: 0.7em;
  width: calc(100% - 1.8em);
  height: calc(100% - 4.5em);
  position: absolute;
  top: 0.9em;
  left: 0.9em;
}

.stacked-cards__controls {
  z-index: 1;
  grid-column-gap: 0.75em;
  grid-row-gap: 0.75em;
  flex-flow: column;
  justify-content: center;
  align-items: center;
  margin-top: -1.5em;
  display: flex;
  position: relative;
}

.shuffle-btn {
  grid-column-gap: 0.375em;
  grid-row-gap: 0.375em;
  color: #121212;
  background-color: #b9baf7;
  border-radius: 10em;
  flex: 0 auto;
  justify-content: center;
  align-items: center;
  height: 2.75em;
  padding-left: 1.25em;
  padding-right: 1.5em;
  font-size: 1.25em;
  font-weight: 400;
  line-height: 1;
  text-decoration: none;
  display: flex;
  position: relative;
}

.shuffle-btn__span {
  white-space: nowrap;
  margin-top: 0.0625em;
  font-size: 1.0625em;
  font-weight: 500;
}

.shuffle-btn__icon-svg {
  width: 1em;
}
script.js
javascript
gsap.registerPlugin(Draggable);

function initStackedCardsSlider() {
  document.querySelectorAll('[data-stacked-cards]').forEach(function(container) {

    // animation presets
    let easeBeforeRelease = { duration: 0.2, ease: 'Power2.easeOut' };
    let easeAfterRelease  = { duration: 1, ease: 'elastic.out(1,0.75)' };

    let activeDeg   = 4;
    let inactiveDeg = -4;

    const list = container.querySelector('[data-stacked-cards-list]');
    if (!list) return;

    // check minimum cards
    const initialItems = Array.from(list.querySelectorAll(':scope > [data-stacked-cards-item]'));
    if (initialItems.length < 3) {
      console.error('[StackedCards] Minimum of 3 cards required. Found:', initialItems.length, list);
      return;
    }

    // Draggable instances & cached elements
    let dragFirst, dragSecond;
    let firstItem, secondItem, firstEl, secondEl;
    let full, t;

    function restack() {
      const items = Array.from(list.querySelectorAll('[data-stacked-cards-item]'));
      items.forEach(function(item) {
        item.classList.remove('is--active', 'is--second');
      });
      items[0].style.zIndex = 3;
      items[0].style.transform = `rotate(${activeDeg}deg)`;
      items[0].style.pointerEvents = 'auto';
      items[0].classList.add('is--active');

      items[1].style.zIndex = 2;
      items[1].style.transform = `rotate(${inactiveDeg}deg)`;
      items[1].style.pointerEvents = 'none';
      items[1].classList.add('is--second');

      items[2].style.zIndex = 1;
      items[2].style.transform = `rotate(${activeDeg}deg)`;

      items.slice(3).forEach(function(item) {
        item.style.zIndex = 0;
        item.style.transform = `rotate(${inactiveDeg}deg)`;
      });
    }

    function setupDraggables() {
      restack();

      // cache top two cards
      const items = Array.from(list.querySelectorAll(':scope > [data-stacked-cards-item]'));
      firstItem   = items[0];
      secondItem  = items[1];
      firstEl     = firstItem.querySelector('[data-stacked-cards-card]');
      secondEl    = secondItem.querySelector('[data-stacked-cards-card]');

      // compute thresholds
      const width = firstEl.getBoundingClientRect().width;
      full = width * 1.15;
      t    = width * 0.1;

      // kill old Draggables
      dragFirst?.kill();
      dragSecond?.kill();

      // --- First card draggable ---
      dragFirst = Draggable.create(firstEl, {
        type: 'x',
        onPress() {
          firstEl.classList.add('is--dragging');
        },
        onRelease() {
          firstEl.classList.remove('is--dragging');
        },
        onDrag() {
          let raw = this.x;
          if (Math.abs(raw) > full) {
            const over = Math.abs(raw) - full;
            raw = (raw > 0 ? 1 : -1) * (full + over * 0.1);
          }
          gsap.set(firstEl, { x: raw, rotation: 0 });
        },
        onDragEnd() {
          const x   = this.x;
          const dir = x > 0 ? 'right' : 'left';

          // hand control to second card
          this.disable?.();
          dragSecond?.enable?.();
          firstItem.style.pointerEvents = 'none';
          secondItem.style.pointerEvents = 'auto';

          if (Math.abs(x) <= t) {
            // small drag: just snap back
            gsap.to(firstEl, {
              x: 0, rotation: 0,
              ...easeBeforeRelease,
              onComplete: resetCycle
            });
          } else if (Math.abs(x) <= full) {
            flick(dir, false, x);
          } else {
            flick(dir, true);
          }
        }
      })[0];

      // --- Second card draggable ---
      dragSecond = Draggable.create(secondEl, {
        type: 'x',
        onPress() {
          secondEl.classList.add('is--dragging');
        },
        onRelease() {
          secondEl.classList.remove('is--dragging');
        },
        onDrag() {
          let raw = this.x;
          if (Math.abs(raw) > full) {
            const over = Math.abs(raw) - full;
            raw = (raw > 0 ? 1 : -1) * (full + over * 0.2);
          }
          gsap.set(secondEl, { x: raw, rotation: 0 });
        },
        onDragEnd() {
          gsap.to(secondEl, {
            x: 0, rotation: 0,
            ...easeBeforeRelease
          });
        }
      })[0];

      // start with first card active
      dragFirst?.enable?.();
      dragSecond?.disable?.();
      firstItem.style.pointerEvents = 'auto';
      secondItem.style.pointerEvents = 'none';
    }

    function flick(dir, skipHome = false, releaseX = 0) {
      if (!(dir === 'left' || dir === 'right')) {
        dir = activeDeg > 0 ? 'right' : 'left';
      }
      dragFirst?.disable?.();

      const item = list.querySelector('[data-stacked-cards-item]');
      const card = item.querySelector('[data-stacked-cards-card]');
      const exitX = dir === 'right' ? full : -full;

      if (skipHome) {
        const visualX = gsap.getProperty(card, 'x');
        list.appendChild(item);
        [activeDeg, inactiveDeg] = [inactiveDeg, activeDeg];
        restack();
        gsap.fromTo(
          card,
          { x: visualX, rotation: 0 },
          { x: 0, rotation: 0, ...easeAfterRelease, onComplete: resetCycle }
        );
      } else {
        gsap.fromTo(
          card,
          { x: releaseX, rotation: 0 },
          {
            x: exitX,
            ...easeBeforeRelease,
            onComplete() {
              gsap.set(card, { x: 0, rotation: 0 });
              list.appendChild(item);
              [activeDeg, inactiveDeg] = [inactiveDeg, activeDeg];
              resetCycle();
              const newCard = item.querySelector('[data-stacked-cards-card]');
              gsap.fromTo(
                newCard,
                { x: exitX },
                { x: 0, ...easeAfterRelease, onComplete: resetCycle }
              );
            }
          }
        );
      }
    }

    function resetCycle() {
      list.querySelectorAll('[data-stacked-cards-card].is--dragging').forEach(function(el) {
        el.classList.remove('is--dragging');
      });
      setupDraggables();
    }

    setupDraggables();

    // "Next" button support
    container.querySelectorAll('[data-stacked-cards-control="next"]').forEach(function(btn) {
      btn.onclick = function() { flick(); };
    });
  });
}

// Initialize Stacked Cards Slider
document.addEventListener("DOMContentLoaded", () => {
  initStackedCardsSlider();
});

Attributes

NameTypeDefaultDescription
data-stacked-cardsstring""Add to the outermost wrapper to initialize a Stacked Cards slider instance. Multiple independent instances on the same page are supported.
data-stacked-cards-liststring""Add to the element containing all card items. The script treats its direct children as the ordered deck and moves them to the end of this list as cards are cycled.
data-stacked-cards-itemstring""Add to each card slot. The script assigns z-index, rotation, and the .is--active / .is--second CSS classes based on the item's position in the deck. Minimum 3 items required.
data-stacked-cards-cardstring""Add to the inner card element inside each item. GSAP Draggable attaches directly to this element — it handles x-axis dragging, resist beyond the threshold, and the flick/snap-back animations.
data-stacked-cards-control"next"""Add to a button with the value "next" to trigger a programmatic flick. Multiple buttons are supported — all are bound automatically. Useful for an explicit shuffle/skip affordance.

Notes

  • A minimum of 3 cards is required. With fewer cards the script logs an error and exits without initialising Draggable.
  • The stack uses DOM ordering — cycling a card appends the current [data-stacked-cards-item] to the end of the list. No index tracking is needed; restack() always reads the current DOM order.
  • activeDeg and inactiveDeg alternate on every flick, giving consecutive cards opposite tilts and creating the visual rhythm of a real card deck.
  • The drag resistance beyond the full threshold (card width × 1.15) is intentional — the card slows but does not stop, giving tactile feedback without feeling stuck.
  • If the drag distance is less than 10% of the card width (the t threshold), the card snaps back with easeBeforeRelease and no cycle occurs — preventing accidental flicks on taps.
  • When the drag exceeds the full threshold on release (skipHome = true), the card moves directly to the end of the DOM and the next card animates in from the exit side, skipping the outward travel.
  • Both the first and second cards have Draggable instances. The second card's draggable is enabled temporarily after the first card is released, preventing accidental simultaneous drags.

Guide

Container, List & Items

Add [data-stacked-cards] to the outer wrapper, [data-stacked-cards-list] to the deck container, and [data-stacked-cards-item] to each card slot. All items must be direct children of the list and at least 3 must be present.

Card element

Inside each [data-stacked-cards-item] place a [data-stacked-cards-card] element. This is the Draggable target and the element that receives GSAP x/rotation transforms. Place your image or card content directly inside it.

Stack sizing

The .stacked-cards__stack uses a padding-top percentage on .stack-cards__before to set the aspect ratio of the stack area. Adjust the padding-top value and the .stack-cards width to resize the entire component.

Rotation customisation

Change activeDeg and inactiveDeg at the top of the initStackedCardsSlider function to adjust the tilt of the top and backing cards. The values alternate direction on every flick.

let activeDeg   = 4;  // degrees for the top card
let inactiveDeg = -4; // degrees for backing cards

Easing customisation

easeBeforeRelease controls the quick outward slide; easeAfterRelease controls the elastic settle when the new card springs into the active position.

let easeBeforeRelease = { duration: 0.2, ease: 'Power2.easeOut' };
let easeAfterRelease  = { duration: 1,   ease: 'elastic.out(1,0.75)' };

Shuffle button

Add [data-stacked-cards-control="next"] to any button inside the container to trigger a programmatic flick. You can add multiple buttons — all are bound to the same flick() call. Place it outside the stack area and use negative margin-top to overlap the bottom of the stack.

CSS class hooks

The script adds .is--active to the top card and .is--second to the one behind it. Use these in CSS to apply box-shadow, opacity, or any other per-layer treatment without additional JavaScript.

.stack-cards__item.is--active .stack-cards__card,
.stack-cards__item.is--second .stack-cards__card {
  box-shadow: 0em 0.5em 0em 0em rgba(0, 0, 0, 0.15);
}