โ† Overlays

Notification Stack Expand

A pile of notifications sits neatly collapsed, the newest on top and the rest peeking out underneath. Tap it and the cards spring apart into a full list, one after another; “Show less” gathers them back up.

MessagesnowMaya: Running 5 minutes late, save me a seat!
Calendar5mDesign review starts in 10 minutes
Parcels20mYour order is out for delivery
Fitness1hYou closed all your rings today!

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-notification-stack-expand to a wrapper and put each notification inside as its own element, newest first.

Options

Add any of these attributes to the same element.

data-titleThe heading above the stack. Default “Notifications”; set it empty for none.
data-peekHow much of each card shows below the one in front. Default 10 px.
data-gapSpace between cards once open. Default 8 px.
data-durationTime for the cards to settle. Default 600 ms.
data-openStart opened out.
data-autoplayTime between opening and closing it by itself while in view, in ms, until someone touches it.

The button in the heading reads “Show all” or “Show less” and keeps aria-expanded up to date; while it’s collapsed the cards behind are hidden from screen readers and links inside can’t be clicked by accident. Cards of different heights are fine. It fires stackexpand and stackcollapse events. From code: animacious.notificationStack(el, true).

1 ยท Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Notification Stack Expand: a pile of notifications sits collapsed, the
    // newest on top with the others peeking out beneath it. Tap the pile and the
    // cards fan out into a full list; "Show less" gathers them up again.
    // Usage:
    //   <div data-notification-stack-expand data-title="Notifications">
    //     <div>Maya: Running 5 minutes late</div>
    //     <div>Calendar: Stand-up in 10 minutes</div>
    //     <div>Parcel: Your order is out for delivery</div>
    //   </div>
    // Each child becomes a card; style them as you like.
    // Optional: data-title (heading above the stack, default "Notifications";
    // set it empty for none), data-peek (px of each card showing below the
    // one in front, default 10), data-gap (px between cards when open, default
    // 8), data-duration (ms, default 600), data-open (start expanded),
    // data-autoplay (ms between opening and closing it by itself while in
    // view, until someone touches it).
    // Events: "stackexpand" and "stackcollapse" on the element.
    // Script use: animacious.notificationStack(el, true) expands it, false collapses it.
    var DEFAULTS = { title: 'Notifications', peek: 10, gap: 8, duration: 600, autoplay: 2600 };

    var css =
      '.nse{position:relative;box-sizing:border-box}' +
      '.nse-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:8px}' +
      '.nse-title{font-weight:700}' +
      '.nse-btn{padding:4px 10px;border:0;border-radius:99px;background:rgba(127,127,127,.16);color:inherit;font:inherit;font-size:.82em;font-weight:600;cursor:pointer}' +
      '.nse-btn:focus-visible{outline:2px solid currentColor;outline-offset:2px}' +
      '.nse-list{position:relative;display:flex;flex-direction:column}' +
      '.nse-card{position:relative;transform-origin:50% 100%}' +
      ':where(.nse-card){box-sizing:border-box;padding:12px 14px;border-radius:16px;background:#fff;color:#1a1625;box-shadow:0 6px 20px rgba(15,10,30,.12)}' +
      '.nse-shut .nse-list{cursor:pointer}' +
      '.nse-shut .nse-list *{pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }

    function setup(el) {
      var d = el.dataset;
      var s = {
        el: el, open: on(d.open),
        peek: d.peek !== undefined ? +d.peek : DEFAULTS.peek,
        gap: d.gap !== undefined ? +d.gap : DEFAULTS.gap,
        duration: +d.duration || DEFAULTS.duration,
        deco: !!el.closest('[aria-hidden="true"]')
      };
      el._nse = s;
      el.classList.add('nse');
      var list = document.createElement('div');
      list.className = 'nse-list';
      list.style.gap = s.gap + 'px';
      s.cards = Array.from(el.children);
      s.cards.forEach(function (c, i) {
        c.classList.add('nse-card');
        c.style.zIndex = s.cards.length - i;
        list.appendChild(c);
      });
      var title = d.title !== undefined ? d.title : DEFAULTS.title;
      var head = document.createElement('div');
      head.className = 'nse-head';
      if (title) {
        var t = document.createElement('span');
        t.className = 'nse-title';
        t.textContent = title;
        head.appendChild(t);
      }
      var btn = document.createElement(s.deco ? 'span' : 'button');
      btn.className = 'nse-btn';
      if (!s.deco) btn.type = 'button';
      head.appendChild(btn);
      el.appendChild(head);
      el.appendChild(list);
      s.list = list; s.btn = btn;
      if (s.cards.length < 2) btn.hidden = true;

      btn.addEventListener('click', function (e) {
        if (e.isTrusted && s.stopAuto) s.stopAuto();
        toggle(s, !s.open);
      });
      list.addEventListener('click', function (e) {
        if (s.open || s.deco) return;
        e.preventDefault();
        if (e.isTrusted && s.stopAuto) s.stopAuto();
        toggle(s, true);
      });
      el.addEventListener('pointerdown', function (e) { if (e.isTrusted && s.stopAuto) s.stopAuto(); });
      var w = 0;
      new ResizeObserver(function () {
        if (list.clientWidth === w) return;
        w = list.clientWidth;
        layout(s, false);
      }).observe(list);
      label(s);
      layout(s, false);
      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) autoplay(s, +d.autoplay || DEFAULTS.autoplay);
      return s;
    }

    function label(s) {
      var n = s.cards.length;
      s.btn.textContent = s.open ? 'Show less' : 'Show all ' + n;
      if (!s.deco) s.btn.setAttribute('aria-expanded', String(s.open));
      s.el.classList.toggle('nse-shut', !s.open);
      if (!s.deco) s.cards.forEach(function (c, i) {
        if (s.open || i === 0) c.removeAttribute('aria-hidden'); else c.setAttribute('aria-hidden', 'true');
        if (!s.open) c.inert = true; else c.inert = false;
      });
    }

    // Where each card goes: its own place in the list, or tucked behind the first.
    function targets(s) {
      var list = s.list, cards = s.cards;
      anime.utils.remove(cards);
      var keep = cards.map(function (c) { return c.style.transform; });
      cards.forEach(function (c) { c.style.transform = 'none'; });
      var prevH = list.style.height;
      list.style.height = 'auto';
      var full = list.offsetHeight;
      list.style.height = prevH;
      var c0 = cards[0], b0 = c0.offsetTop + c0.offsetHeight, h0 = c0.offsetHeight;
      var out = cards.map(function (c, i) {
        if (s.open || i === 0) return { y: 0, scaleX: 1, scaleY: 1, opacity: 1, inner: 1 };
        var k = Math.min(i, 3), sx = 1 - 0.05 * k;
        return {
          y: b0 + s.peek * Math.min(i, 2) - (c.offsetTop + c.offsetHeight),
          scaleX: sx, scaleY: (h0 * sx) / c.offsetHeight,
          opacity: i > 2 ? 0 : 1 - 0.12 * i, inner: 0
        };
      });
      cards.forEach(function (c, i) { c.style.transform = keep[i]; });
      var height = s.open ? full : h0 + s.peek * Math.min(cards.length - 1, 2);
      return { cards: out, height: height };
    }

    function layout(s, animate) {
      if (!s.cards.length) return;
      var t = targets(s);
      anime.utils.remove(s.list);
      var still = !animate || reduced();
      s.cards.forEach(function (c, i) {
        var p = t.cards[i], kids = Array.from(c.children);
        anime.utils.remove(kids);
        if (still) {
          anime.utils.set(c, { y: p.y, scaleX: p.scaleX, scaleY: p.scaleY, opacity: p.opacity });
          anime.utils.set(kids, { opacity: p.inner });
          return;
        }
        var order = s.open ? i : s.cards.length - i;
        anime.animate(c, {
          y: p.y, scaleX: p.scaleX, scaleY: p.scaleY, opacity: p.opacity,
          delay: order * 35,
          ease: anime.spring({ bounce: s.open ? 0.28 : 0.12, duration: s.duration })
        });
        anime.animate(kids, { opacity: p.inner, duration: s.open ? 300 : 160, delay: s.open ? order * 35 + 120 : 0, ease: 'out(2)' });
      });
      if (still) { s.list.style.height = t.height + 'px'; return; }
      anime.animate(s.list, { height: t.height, ease: anime.spring({ bounce: s.open ? 0.2 : 0.05, duration: s.duration }) });
    }

    function toggle(s, open) {
      if (s.open === open || s.cards.length < 2) return;
      s.open = open;
      label(s);
      if (!open && s.el.contains(document.activeElement) && document.activeElement !== s.btn) s.btn.focus();
      s.el.dispatchEvent(new CustomEvent(open ? 'stackexpand' : 'stackcollapse', { bubbles: true }));
      layout(s, true);
    }

    function autoplay(s, ms) {
      var timer = null, visible = false, live = true;
      function tick() {
        timer = setTimeout(function () {
          if (!visible || !live) return;
          toggle(s, !s.open);
          tick();
        }, s.open ? ms * 1.2 : ms);
      }
      s.stopAuto = function () { live = false; clearTimeout(timer); };
      new IntersectionObserver(function (entries) {
        visible = entries[entries.length - 1].isIntersecting;
        clearTimeout(timer);
        if (visible && live) tick();
      }, { threshold: 0.4 }).observe(s.el);
    }

    function init() {
      document.querySelectorAll('[data-notification-stack-expand]').forEach(function (el) {
        if (el._nse) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.notificationStack = function (el, open) {
      var s = el._nse || setup(el);
      toggle(s, open === undefined ? !s.open : !!open);
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 ยท Your HTML
<div data-notification-stack-expand data-title="Today">
  <div>๐Ÿ’ฌ Maya: Running 5 minutes late</div>
  <div>๐Ÿ“… Design review in 10 minutes</div>
  <div>๐Ÿ“ฆ Your order is out for delivery</div>
</div>