← Scroll

Staggered Cascade

When a grid or list scrolls into view its items reveal one after another, rippling diagonally from the corner. Each one tips up from slightly below, scaling and sharpening into place. It reads the rows and columns from your layout.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-staggered-cascade to the grid or list. Its direct children are the items that cascade in.

Options

Add any of these attributes to the same element.

data-fromWhere the ripple starts: first (default), center or last.
data-staggerGap between neighbouring items. Default 80 ms.
data-durationHow long each item takes. Default 900 ms.
data-distanceHow far items rise, in px. Default 40.
data-repeatPlay again every time it comes back into view.
data-loopHide and cascade in again every few seconds while in view.

Any CSS grid, flexbox row or plain list works. For separate elements dotted down a page, use Fade-up on Scroll.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Staggered Cascade: when a grid or list scrolls into view its items reveal
    // one after another, rippling diagonally across the grid. Each item tips up
    // from slightly below, scaling and sharpening into place.
    // Usage: <div class="grid" data-staggered-cascade>
    //          <div>…</div><div>…</div><div>…</div>
    //        </div>
    // Optional: data-from ("first" (default), "center" or "last": where the
    // ripple starts), data-stagger (ms between neighbouring items, default 80),
    // data-duration (ms each item takes, default 900), data-distance (px items
    // rise, default 40), data-repeat (play again each time it re-enters the
    // view), data-loop (hide and replay every few seconds while in view).
    // It works out the rows and columns from the layout, so any CSS grid,
    // flexbox row or plain list will do.
    var DEFAULTS = { stagger: 80, duration: 900, distance: 40, hold: 2600 };

    var css = '[data-staggered-cascade]{perspective:900px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function opts(el) {
      var d = el.dataset;
      return {
        from: d.from || 'first',
        stagger: +d.stagger || DEFAULTS.stagger,
        duration: +d.duration || DEFAULTS.duration,
        distance: d.distance !== undefined ? +d.distance : DEFAULTS.distance,
        loop: d.loop !== undefined && d.loop !== 'false',
        repeat: d.repeat !== undefined && d.repeat !== 'false'
      };
    }

    // Columns are the items that share the first row; rows follow from that.
    function grid(items) {
      var top = Math.round(items[0].getBoundingClientRect().top), cols = 0;
      for (var i = 0; i < items.length; i++) {
        if (Math.abs(Math.round(items[i].getBoundingClientRect().top) - top) > 2) break;
        cols++;
      }
      cols = Math.max(1, cols);
      return [cols, Math.ceil(items.length / cols)];
    }

    function hide(c) {
      anime.utils.set(c.items, { opacity: 0, y: c.o.distance, scale: 0.86, rotateX: 28, filter: 'blur(6px)' });
    }

    function reveal(c) {
      c.anim = anime.animate(c.items, {
        opacity: [0, 1],
        y: [c.o.distance, 0],
        scale: [0.86, 1],
        rotateX: [28, 0],
        filter: ['blur(6px)', 'blur(0px)'],
        duration: c.o.duration,
        delay: anime.stagger(c.o.stagger, { grid: grid(c.items), from: c.o.from }),
        ease: 'out(4)',
        onComplete: function () {
          if (c.o.loop) c.timer = setTimeout(function () { c.timer = null; if (c.inView) leave(c); else c.waiting = true; }, DEFAULTS.hold);
        }
      });
    }

    // Looping: the items drop away in the same order, then cascade back in.
    function leave(c) {
      c.waiting = false;
      c.anim = anime.animate(c.items, {
        opacity: 0,
        y: -c.o.distance / 2,
        scale: 0.92,
        filter: 'blur(4px)',
        duration: c.o.duration * 0.5,
        delay: anime.stagger(c.o.stagger * 0.6, { grid: grid(c.items), from: c.o.from }),
        ease: 'in(2)',
        onComplete: function () { hide(c); setTimeout(function () { reveal(c); }, 250); }
      });
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      if (reduced) return;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._scc;
          c.inView = e.isIntersecting;
          if (e.isIntersecting) {
            // Looping and it paused while out of view: carry on.
            if (c.shown) { if (c.waiting) leave(c); return; }
            c.shown = true;
            reveal(c);
            if (!c.o.repeat && !c.o.loop) io.unobserve(e.target);
          } else if (c.o.repeat && c.shown && e.boundingClientRect.top > 0) {
            // Left through the bottom: hide again so it replays on the way back.
            if (c.anim) c.anim.pause();
            clearTimeout(c.timer);
            c.shown = c.waiting = false;
            hide(c);
          }
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-staggered-cascade]').forEach(function (el) {
        if (el._scc) return;
        var items = Array.prototype.slice.call(el.children);
        if (!items.length) return;
        var c = (el._scc = { el: el, items: items, o: opts(el) });
        hide(c);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div class="grid" data-staggered-cascade>
  <div class="card">…</div>
  <div class="card">…</div>
  <div class="card">…</div>
  <div class="card">…</div>
</div>