← Scroll

Sticky Card Stack

Cards pin near the top of the view as you scroll, and each new card slides up over the last until they pile into a deck. Covered cards shrink back and dim a little, leaving their top edges peeking out.

Scroll inside ↓

01
DesignSketch the idea
02
BuildMake it real
03
ShipPut it out there
04
GrowListen and improve

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-sticky-card-stack to the element holding the cards. Each direct child becomes a card.
  3. Space the cards out with margin-top (not margin-bottom, which cuts their pinning short): it sets how long each one takes to slide over the last.

Options

Add any of these attributes to the same element.

data-topWhere the cards pin, in px from the top of the view. Default 24.
data-offsetHow much lower each card pins than the one before, so the edges show. Default 14 px.
data-scaleHow much smaller each covered card gets. Default 0.06.
data-dimHow much darker each covered card gets. Default 0.12.

The pinning is plain CSS position: sticky, so nothing between the cards and the page can have overflow: hidden. Works inside scrolling boxes too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Sticky Card Stack: cards pin near the top of the view as you scroll and
    // the next card slides up over them, so they pile into a deck. Cards that
    // get covered shrink back and dim a little, leaving their top edge peeking
    // out above the card in front.
    // Usage: <div data-sticky-card-stack>
    //          <div class="card">…</div>
    //          <div class="card">…</div>
    //          <div class="card">…</div>
    //        </div>
    // Optional: data-top (px from the top of the view where cards pin, default
    // 24), data-offset (px each card pins lower than the one before, default
    // 14), data-scale (how much smaller each covered card gets, default 0.06),
    // data-dim (how much darker, default 0.12).
    // Space the cards out with margin-top (not margin-bottom, which would cut
    // their pinning short) to set how long each one takes to slide over the last.
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { top: 24, offset: 14, scale: 0.06, dim: 0.12 };

    function scroller(el) {
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var o = getComputedStyle(p).overflowY;
        if ((o === 'auto' || o === 'scroll') && p.scrollHeight > p.clientHeight) return p;
      }
      return null;
    }

    function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }

    function setup(el) {
      var d = el.dataset;
      var top = num(d.top, DEFAULTS.top), offset = num(d.offset, DEFAULTS.offset);
      var scale = num(d.scale, DEFAULTS.scale), dim = num(d.dim, DEFAULTS.dim);
      var cards = Array.prototype.slice.call(el.children).map(function (c, i) {
        c.style.position = 'sticky';
        c.style.top = top + i * offset + 'px';
        c.style.transformOrigin = '50% 0';
        c.style.willChange = 'transform, filter';
        return { el: c, pin: top + i * offset };
      });
      var box = scroller(el);
      var queued = false;

      function update() {
        queued = false;
        var viewTop = box ? box.getBoundingClientRect().top + box.clientTop : 0;
        // How far each card has slid over the one before it: 0 while it's still
        // a card's height away, 1 once it has pinned on top.
        var cover = cards.map(function (c, i) {
          if (!i) return 0;
          var prev = cards[i - 1].el.offsetHeight || 1;
          var gap = c.el.getBoundingClientRect().top - viewTop - c.pin;
          return Math.max(0, Math.min(1, 1 - gap / prev));
        });
        cards.forEach(function (c, i) {
          // Every card in front adds to how far back this one sits.
          var depth = 0;
          for (var j = i + 1; j < cards.length; j++) depth += cover[j];
          if (Math.abs(depth - (c.depth || 0)) < 0.002) return;
          c.depth = depth;
          anime.utils.set(c.el, {
            scale: 1 - scale * depth,
            filter: depth > 0.001 ? 'brightness(' + (1 - dim * Math.min(depth, 3)).toFixed(3) + ')' : 'none'
          });
        });
      }

      function onScroll() {
        if (queued) return;
        queued = true;
        requestAnimationFrame(update);
      }
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', onScroll);
      update();
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      document.querySelectorAll('[data-sticky-card-stack]').forEach(function (el) {
        if (el._scs) return;
        el._scs = true;
        // With reduced motion the cards still pin and stack (that's just
        // scrolling), they just don't shrink and dim.
        if (reduced) {
          var d = el.dataset, top = num(d.top, DEFAULTS.top), offset = num(d.offset, DEFAULTS.offset);
          Array.prototype.forEach.call(el.children, function (c, i) { c.style.position = 'sticky'; c.style.top = top + i * offset + 'px'; });
          return;
        }
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-sticky-card-stack>
  <div class="card">…</div>
  <div class="card" style="margin-top: 40vh">…</div>
  <div class="card" style="margin-top: 40vh">…</div>
</div>