---
name: card-stack-scroll
description: Use when you want "Premium, structured, engaging" - Stacks cards that move, pin, or peel away during scroll.
---

# Card Stack Scroll

> **Category:** Scroll  -  **Personality:** Premium, structured, engaging
>
> **Best use:** Feature explanations, portfolios
>
> **Ratings:** Professional 5/5 - Casual 4/5 - Exotic 3/5 - Visual impact 5/5 - Difficulty 4/5 - Performance cost 3/5

## What it is

A short sequence of full-size cards that share one spot on screen. As you scroll, the page **pins** the deck in place and a **scrubbed timeline** drives the cards: the front card lifts, shrinks and blurs back into the stack while the next card rises up over it — so features "peel" past one at a time instead of scrolling by. Because each card gets a generous slice of scroll distance, the reader dwells on one idea at a time, which is why it suits feature walk-throughs and portfolio pillars. It reads as premium and deliberate (think Apple product pages).

The motion is **scroll-linked, not autoplaying** — scrubbing ties timeline progress directly to scroll position, so the user is always in control. A reduced-motion (or no-JS) fallback simply renders the same cards as a normal vertical list.

## Dependencies / CDN

GSAP core + the ScrollTrigger plugin, version-pinned. Put these at the end of the demo markup, *before* your init script runs:

```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js" integrity="sha384-g4NTh/Iv5PPU4xPyhEWqPcwtNXOvdaDI8LLnyYfyNZOjKJeYQyjzQ9X5275eBjpt" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js" integrity="sha384-Z3REaz79l2IaAZqJsSABtTbhjgOUYyV3p90XNnAPCSHg3EMTz1fouunq9WZRtj3d" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
```

## HTML

A hero (normal flow) → the pinned deck → an outro (normal flow). The deck holds the cards stacked in source order; **card 0 is the first one seen**. Each card carries a `data-name` used by the progress readout.

```html
<section class="cs-root">
  <header class="cs-hero">
    <h2 class="cs-hero-title">Engineered to be <em>felt</em>, not merely heard.</h2>
    <p class="cs-hero-sub">Four obsessions define every pair. Keep scrolling.</p>
  </header>

  <div class="cs-pin">                <!-- this wrapper gets pinned -->
    <div class="cs-stage">
      <div class="cs-progress" aria-hidden="true">
        <div class="cs-prog-track"><span class="cs-prog-fill"></span></div>
        <div class="cs-prog-meta">
          <span class="cs-prog-name">Adaptive Silence</span>
          <span class="cs-prog-count"><b>01</b> / 04</span>
        </div>
      </div>

      <article class="cs-card" data-name="Adaptive Silence">
        <div class="cs-card-inner">
          <div class="cs-text">
            <span class="cs-index">01</span>
            <span class="cs-icon"><svg viewBox="0 0 24 24"><!-- icon --></svg></span>
            <span class="cs-kicker">Acoustic Isolation</span>
            <h3 class="cs-title">Adaptive Silence</h3>
            <p class="cs-copy">Eight feed-forward microphones map the room 48,000 times a second…</p>
            <div class="cs-specs">
              <span class="cs-spec"><b>&minus;42 dB</b> peak ANC</span>
              <span class="cs-spec"><b>8</b> mics</span>
            </div>
          </div>
          <div class="cs-media">
            <img src="/front-end-design-effects-catalog/assets/img/product-headphones.jpg" alt="" loading="lazy">
            <span class="cs-tag">01 &middot; Isolation</span>
          </div>
        </div>
      </article>

      <!-- …repeat .cs-card for 02, 03, 04 … -->
    </div>
  </div>

  <footer class="cs-outro"> … </footer>
</section>
```

## CSS

The **base styles are the fallback** — cards sit in a normal vertical list. The JS adds `.is-animated` only when it actually drives the animation; that class is what flips the deck into a single pinned, absolutely-stacked layer. (Decorative card/typography styling trimmed here.)

```css
/* BASE = static fallback: a plain vertical list of full cards */
.cs-pin   { padding: 6px 0 24px; }
.cs-stage { position: relative; width: 100%; max-width: 940px; margin: 0 auto; padding: 0 20px; }
.cs-card  { position: relative; width: 100%; margin: 0 auto 22px; }
.cs-progress { display: none; }                 /* progress rail only matters when animated */

/* ANIMATED MODE — added by JS when motion is allowed */
.cs-root.is-animated .cs-pin   { min-height: 100vh; display: flex;
                                 align-items: center; justify-content: center; padding: 0; }
.cs-root.is-animated .cs-stage { height: clamp(472px, 64vh, 560px); padding: 0 20px; }
.cs-root.is-animated .cs-card  { position: absolute; inset: 0 20px; margin: 0; width: auto;
                                 will-change: transform, opacity, filter; }   /* all cards share one spot */
.cs-root.is-animated .cs-progress { display: flex; }

/* CARD SURFACE (premium dark + copper) */
.cs-card-inner { height: 100%; min-height: 362px; display: grid; grid-template-columns: 1.08fr .92fr;
  background: linear-gradient(155deg, #27201a, #1e1812);
  border: 1px solid rgba(244,236,223,.10); border-radius: 22px; overflow: hidden;
  box-shadow: 0 38px 84px -34px rgba(0,0,0,.82), inset 0 1px 0 rgba(255,236,214,.08); }
.cs-media img { width: 100%; height: 100%; object-fit: cover;
  filter: grayscale(.42) contrast(1.04) brightness(.82); }   /* unifies disparate photos */

/* On phones the deck needs more height; cards become single-column */
@media (max-width: 760px){
  .cs-card-inner { grid-template-columns: 1fr; min-height: 0; }
  .cs-media { order: -1; min-height: 172px; max-height: 200px; }
  .cs-root.is-animated .cs-stage { height: clamp(560px, 84vh, 664px); }
}
```

## JavaScript

This is the whole effect. It bails out (leaving the static list) when reduced-motion is requested or GSAP is missing, otherwise it pins `.cs-pin` and builds one scrubbed transition per gap between cards.

```js
(function(){
  var root  = document.querySelector('.cs-root');
  if(!root) return;
  var cards = Array.prototype.slice.call(root.querySelectorAll('.cs-card'));
  var N = cards.length;
  var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;

  // No animation: leave the static stack (CSS default) untouched.
  if(reduce || !window.gsap || !window.ScrollTrigger || N < 2) return;

  gsap.registerPlugin(ScrollTrigger);
  root.classList.add('is-animated');

  var fill    = root.querySelector('.cs-prog-fill');
  var nameEl  = root.querySelector('.cs-prog-name');
  var countEl = root.querySelector('.cs-prog-count b');
  var names   = cards.map(function(c){ return c.getAttribute('data-name') || ''; });

  // Card 0 sits front-and-centre; the rest wait just below the stage.
  cards.forEach(function(card, i){
    gsap.set(card, { zIndex:i+1, transformOrigin:'50% 0%', autoAlpha:1, rotation:0,
                     yPercent: i === 0 ? 0 : 100, scale:1, filter:'blur(0px)' });
  });

  function setUI(p){
    if(fill) fill.style.width = (8 + p * 92).toFixed(1) + '%';
    var idx = Math.min(N - 1, Math.max(0, Math.round(p * (N - 1))));
    if(nameEl)  nameEl.textContent  = names[idx];
    if(countEl) countEl.textContent = ('0' + (idx + 1)).slice(-2);
  }
  setUI(0);

  var tl = gsap.timeline({
    defaults:{ ease:'power2.inOut', duration:1 },
    scrollTrigger:{
      trigger: root.querySelector('.cs-pin'),
      start: 'top top',
      end: function(){ return '+=' + (window.innerHeight * N); }, // generous pin range
      pin: true, scrub: 0.7, anticipatePin: 1, invalidateOnRefresh: true,
      onUpdate: function(self){ setUI(self.progress); }
    }
  });

  // One transition per gap: current card recedes into the stack, next rises to the top,
  // and the card two steps back is pushed away so only ~one peeled card stays visible.
  for(var t = 0; t < N - 1; t++){
    var cur = cards[t], next = cards[t + 1], prev = cards[t - 1];
    tl.to(cur,  { yPercent:-6,  scale:0.94, rotation:-2, filter:'blur(3px)', autoAlpha:0.55 }, t);
    tl.to(next, { yPercent:0,   rotation:0 }, t);
    if(prev) tl.to(prev, { yPercent:-13, scale:0.88, rotation:-4, filter:'blur(7px)', autoAlpha:0 }, t);
  }

  // Recompute pin math once fonts/images have settled.
  window.addEventListener('load', function(){ ScrollTrigger.refresh(); });
})();
```

## How it works

- **Pin.** `pin: true` on a `.cs-pin` wrapper that is `min-height:100vh` freezes the deck in the viewport. ScrollTrigger inserts a "pin-spacer" that reserves the scroll distance, so the rest of the page keeps flowing normally before and after.
- **Scrub.** `scrub: 0.7` ties the timeline's playhead to scroll position (the `0.7` adds a little smoothing lag), so the cards move *only* as fast as the user scrolls — forward and backward.
- **Generous range.** `end: "+=" + innerHeight * N` gives the pin ~`N` viewport-heights of scroll. With `N-1` transitions placed at integer times `0,1,2…`, each peel gets a comfortable slice so the motion reads clearly instead of snapping.
- **The peel.** Every card is absolutely stacked in the same spot with `transform-origin: 50% 0%`. Per transition the *current* card lifts a touch, scales to `.94`, blurs and dims to `.55` (it recedes behind, a sliver peeking at the top); the *next* card slides from `yPercent:100` (just below) up to `0`; the card *two back* is pushed further and faded to `0` so the deck never piles up more than ~one visible layer.
- **Readout.** `onUpdate` maps `self.progress` (0→1) to the active index, filling the progress bar and swapping the feature name / `01–04` counter.

## Customization

- **More / fewer cards:** just add `.cs-card` markup — the loop and the `end` range scale off `N` automatically.
- **Peel feel:** tune the recede tween. Bigger `scale` drop + more `blur` = deeper stack; drop `rotation` for a clean vertical pile, or increase it for a looser "thrown card" look.
- **Pacing:** raise the `innerHeight * N` multiplier for slower, more cinematic peels; lower `scrub` toward `0` for tighter tracking or up to `~1.5` for a floatier feel.
- **Direction:** swap `yPercent` for `xPercent` to slide cards in from the side, or animate `clip-path` for a true "wipe" reveal.
- **Theme:** the demo is a warm copper-on-near-black audio brand; recolour via the `--cs-*` custom properties and the `.cs-media img` filter to retheme without touching the motion.

## Accessibility & performance

- **Reduced motion:** the script returns early on `prefers-reduced-motion: reduce`, leaving the CSS default — a readable vertical list of the same cards, no pin, no scroll-jacking.
- **Progressive enhancement:** the static list is also what renders with no/blocked JS or if the CDN fails; `.is-animated` (and the absolute stacking) is only applied once GSAP is confirmed present.
- **Cheap properties only:** transforms, `opacity`/`autoAlpha` and `filter` are GPU-friendly; `will-change` is set on the cards. Avoid animating layout properties (width/height/top) here.
- **Layout settling:** call `ScrollTrigger.refresh()` on `window.load` so late fonts/images don't leave the pin math (start/end) stale.
- **Content order:** cards live in the DOM in reading order, so screen readers and keyboard users get the full content regardless of the visual stacking.

## Gotchas

- **Pin the wrapper, not the stage.** `pin: true` must target an element sized to the viewport (`min-height:100vh`); pinning the inner card grid leads to jumps.
- **No transformed ancestor.** A parent with `transform`, `filter`, `will-change:transform` or `contain` turns `position:fixed` into a local containing block and the pin will drift or not stick. `overflow:hidden` alone (as on `.cs-root`) is fine because the window is the scroller.
- **Set initial states explicitly.** Without `gsap.set(...)` placing later cards at `yPercent:100`, they all sit on card 0 and you see a static pile on load.
- **Watch the range vs. transition count.** Too short an `end` crams every peel into a flick of scroll; the `innerHeight * N` formula keeps each step comfortable — scale it, don't drop it.
- **Pin range = empty-looking scroll.** While pinned the page "doesn't move," which is the intended dwell. Keep it generous but finite, and give clear before/after content so users understand the section ended.
- **Version-pin the CDN.** GSAP and ScrollTrigger versions must match; an unpinned `@latest` can break the timeline API mid-project.
