← Overlays

Stacked Sheets

Open a sheet and the page shrinks back and dims as the sheet slides up over it, just like a phone app presenting a card. Open another from inside and the first steps back in turn, peeking out above the new one; close them and everything springs back.

Photos
Share 3 photos
πŸ’¬βœ‰οΈπŸ”—πŸ“Œ
Choose an album

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-stacked-sheets to a wrapper (or <body>), and data-stack-page to the page content inside it.
  3. Add data-stack-sheet and an id to each sheet, also inside the wrapper.
  4. Add data-stack-open="#that-id" to the buttons that open them (including buttons inside a sheet, to stack another), and data-stack-close to the ones that close them.

Options

Add any of these attributes to the same element.

data-topHow much of the page shows above a sheet, in px. Default 48 (10% of the height when contained).
data-durationTime for a sheet to slide up. Default 560 ms.
data-containedKeep everything inside the wrapper, for app frames and previews.
data-autoplayTime between steps, in ms: it opens the sheets one by one and closes them again while in view, until someone touches it.

Each sheet is a dialog: focus moves into it and back to the button that opened it, and the page stops scrolling underneath. Esc, or a tap on the dimmed part behind, closes the top sheet, and closing a sheet closes any stacked on it. Each sheet fires sheetopen and sheetclose events. From code: animacious.stackSheet(sheet).

1 Β· Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Stacked Sheets: open a sheet and the page shrinks back and dims as the
    // sheet slides up over it, the way phone apps present a card. Open another
    // from inside and the first sheet steps back in turn, peeking out above the
    // new one; close them and everything springs back into place.
    // Usage:
    //   <div data-stacked-sheets>
    //     <main data-stack-page> … <button data-stack-open="#share">Share</button></main>
    //     <section id="share" data-stack-sheet aria-label="Share">
    //       … <button data-stack-open="#album">Add to album</button>
    //       <button data-stack-close>Done</button>
    //     </section>
    //     <section id="album" data-stack-sheet aria-label="Albums">…</section>
    //   </div>
    // Optional on the wrapper: data-top (px of page left showing above a sheet,
    // default 48, or 10% of the height when contained), data-duration (ms,
    // default 560), data-contained (everything stays inside the wrapper, for
    // previews and app frames), data-autoplay (ms between steps: it opens the
    // sheets one by one and closes them again while in view, until someone
    // touches it).
    // Esc, or a tap on the dimmed part behind, closes the top sheet.
    // Events: "sheetopen" and "sheetclose" on each sheet.
    // Script use: animacious.stackSheet(sheet) opens it, animacious.stackSheet(sheet, false) closes it.
    var DEFAULTS = { top: 48, duration: 560, autoplay: 1500 };

    var css =
      '.sts-page{position:relative;transform-origin:50% 0;z-index:1}' +
      '.sts-c{position:relative;overflow:hidden;background:#0d0b14}' +
      '.sts-c>.sts-page{position:absolute;inset:0;overflow:hidden}' +
      '.sts-back{position:fixed;inset:0;z-index:0;background:#0d0b14;visibility:hidden}' +
      '.sts-shade{position:absolute;inset:0;z-index:50;background:#000;opacity:0;visibility:hidden;border-radius:inherit;-webkit-tap-highlight-color:transparent}' +
      '.sts-sheet{position:fixed;left:0;right:0;bottom:0;z-index:9200;box-sizing:border-box;margin:0 auto;max-width:720px;overflow:hidden auto;overscroll-behavior:contain;transform-origin:50% 0;visibility:hidden}' +
      '.sts-c>.sts-sheet{position:absolute;max-width:none;z-index:2}' +
      ':where(.sts-sheet){padding:22px 20px;border-radius:18px 18px 0 0;background:#fff;color:#1a1625;box-shadow:0 -10px 40px rgba(0,0,0,.25)}' +
      ':where(.sts-sheet):focus{outline: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 shade(node) {
      var s = document.createElement('div');
      s.className = 'sts-shade';
      s.setAttribute('aria-hidden', 'true');
      node.appendChild(s);
      return s;
    }

    function setup(root) {
      var d = root.dataset;
      var r = {
        root: root, stack: [],
        contained: on(d.contained),
        duration: +d.duration || DEFAULTS.duration,
        deco: !!root.closest('[aria-hidden="true"]'),
        page: root.querySelector('[data-stack-page]') || root
      };
      root._sts = r;
      if (r.contained) root.classList.add('sts-c');
      r.page.classList.add('sts-page');
      r.page._stsShade = shade(r.page);
      if (!r.contained) {
        var back = document.createElement('div');
        back.className = 'sts-back';
        back.setAttribute('aria-hidden', 'true');
        document.body.insertBefore(back, document.body.firstChild);
        r.back = back;
      }
      r.sheets = Array.from(root.querySelectorAll('[data-stack-sheet]'));
      r.sheets.forEach(function (s) {
        s._sts = r;
        s.classList.add('sts-sheet');
        s._stsShade = shade(s);
        if (!r.deco) {
          if (!s.getAttribute('role')) s.setAttribute('role', 'dialog');
          if (!r.contained) s.setAttribute('aria-modal', 'true');
          if (!s.hasAttribute('tabindex')) s.tabIndex = -1;
        }
      });
      [r.page].concat(r.sheets).forEach(function (n) {
        n._stsShade.addEventListener('click', function () { closeTop(r); });
      });
      root.addEventListener('keydown', function (e) {
        if (e.key === 'Escape' && r.stack.length) { e.stopPropagation(); closeTop(r, true); }
      });
      return r;
    }

    function top(r) {
      var d = r.root.dataset;
      if (d.top !== undefined) return +d.top;
      return r.contained ? Math.max(22, Math.round(r.root.clientHeight * 0.1)) : DEFAULTS.top;
    }

    // Lay everything out for the current stack: the further back, the smaller,
    // higher and darker.
    function arrange(r, instant) {
      var gap = top(r), t = r.duration, rm = instant || reduced();
      var layers = [r.page].concat(r.stack);
      layers.forEach(function (n, i) {
        var depth = layers.length - 1 - i;
        var isPage = n === r.page;
        var scale = depth ? Math.max(0.82, 1 - 0.055 * depth) : 1;
        var y = isPage ? (depth ? Math.max(6, gap * 0.45 - 8 * (depth - 1)) : 0) : (depth ? -9 * depth : 0);
        var props = { scale: scale, y: y };
        if (isPage) props.borderRadius = depth ? 14 : 0;
        var dim = depth ? Math.min(0.55, (isPage ? 0.28 : 0.2) * depth) : 0;
        var sh = n._stsShade;
        sh.style.visibility = depth ? 'visible' : 'hidden';
        anime.utils.remove([n, sh]);
        if (rm) { anime.utils.set(n, props); anime.utils.set(sh, { opacity: dim }); return; }
        props.ease = anime.spring({ bounce: 0.12, duration: t });
        anime.animate(n, props);
        anime.animate(sh, { opacity: dim, duration: t * 0.8, ease: 'out(2)' });
      });
      if (r.back) r.back.style.visibility = r.stack.length ? 'visible' : 'hidden';
    }

    function open(r, s, byUser) {
      if (r.stack.indexOf(s) > -1) return;
      var first = !r.stack.length;
      r.stack.push(s);
      var gap = top(r);
      s.style.top = gap + 'px';
      s.style.visibility = 'visible';
      s.style.zIndex = (r.contained ? 2 : 9200) + r.stack.length;
      if (first && !r.contained && !r.deco) {
        r.scroll = document.documentElement.style.overflow;
        document.documentElement.style.overflow = 'hidden';
      }
      if (!r.deco) s._stsBack = document.activeElement;
      s.dispatchEvent(new CustomEvent('sheetopen', { bubbles: true }));
      arrange(r);
      anime.utils.remove(s);
      var kids = Array.from(s.children).filter(function (k) { return k !== s._stsShade; });
      if (reduced()) anime.utils.set(s, { y: 0, scale: 1 });
      else {
        anime.animate(s, { y: [s.offsetHeight + 20, 0], scale: 1, duration: r.duration, ease: 'out(4)' });
        anime.animate(kids, { opacity: [0, 1], y: [16, 0], duration: r.duration * 0.8, delay: anime.stagger(50, { start: r.duration * 0.25 }), ease: 'out(3)' });
      }
      if (byUser && !r.deco) setTimeout(function () { if (r.stack.indexOf(s) > -1) s.focus({ preventScroll: true }); }, 50);
    }

    function close(r, s, byUser) {
      var i = r.stack.indexOf(s);
      if (i < 0) return;
      // Closing one closes any opened from it too.
      r.stack.slice(i + 1).reverse().forEach(function (o) { close(r, o); });
      r.stack.splice(r.stack.indexOf(s), 1);
      s.dispatchEvent(new CustomEvent('sheetclose', { bubbles: true }));
      if (!r.deco) {
        if (s.contains(document.activeElement)) {
          var back = s._stsBack;
          if (back && back.focus && back !== document.body) back.focus({ preventScroll: true });
          else document.activeElement.blur();
        }
        if (!r.stack.length && !r.contained) document.documentElement.style.overflow = r.scroll || '';
      }
      anime.utils.remove(s);
      var done = function () { if (r.stack.indexOf(s) < 0) s.style.visibility = 'hidden'; };
      if (reduced()) done();
      else anime.animate(s, { y: s.offsetHeight + 30, scale: 1, duration: r.duration * 0.7, ease: 'inOut(3)', onComplete: done });
      arrange(r);
    }

    function closeTop(r, byUser) {
      if (r.stack.length) close(r, r.stack[r.stack.length - 1], byUser);
    }

    // Demo mode: open each sheet in turn, then close them one by one.
    function autoplay(r, every) {
      var timer = null, visible = false, step = 0;
      r.auto = true;
      function tick() {
        clearTimeout(timer);
        if (!visible || !r.auto) return;
        var n = r.sheets.length, k = step++ % (2 * n + 1);
        if (k < n) open(r, r.sheets[k]);
        else if (k < 2 * n) closeTop(r);
        timer = setTimeout(tick, k === 2 * n ? 600 : k === n - 1 ? every * 1.4 : every);
      }
      r.root.addEventListener('pointerdown', function (e) { if (e.isTrusted) { r.auto = false; clearTimeout(timer); } });
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) { clearTimeout(timer); timer = setTimeout(tick, 700); }
        else clearTimeout(timer);
      }, { threshold: 0.4 }).observe(r.root);
    }

    function find(sel) { try { return document.querySelector(sel); } catch (e) { return null; } }

    document.addEventListener('click', function (e) {
      var t = e.target.closest && e.target.closest('[data-stack-open],[data-stack-close]');
      if (!t) return;
      if (t.hasAttribute('data-stack-open')) {
        var s = find(t.getAttribute('data-stack-open'));
        if (!s || !s._sts) return;
        s._sts.auto = false;
        open(s._sts, s, true);
      } else {
        var sheet = t.closest('.sts-sheet');
        if (sheet) { sheet._sts.auto = false; close(sheet._sts, sheet, true); }
      }
    });
    document.addEventListener('keydown', function (e) {
      if (e.key !== 'Escape') return;
      document.querySelectorAll('[data-stacked-sheets]').forEach(function (root) {
        var r = root._sts;
        if (r && !r.contained && r.stack.length) closeTop(r, true);
      });
    });

    function init() {
      document.querySelectorAll('[data-stacked-sheets]').forEach(function (root) {
        if (root._sts) return;
        var r = setup(root);
        if (on(root.dataset.autoplay) && !reduced() && r.sheets.length) autoplay(r, +root.dataset.autoplay || DEFAULTS.autoplay);
        if ('ResizeObserver' in window) new ResizeObserver(function () {
          if (!r.stack.length) return;
          r.stack.forEach(function (s) { s.style.top = top(r) + 'px'; });
          arrange(r, true);
        }).observe(root);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.stackSheet = function (s, show) {
      var r = s._sts;
      if (!r) return;
      if (show === false) close(r, s, true); else open(r, s, true);
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 Β· Your HTML
<body data-stacked-sheets>
  <main data-stack-page>
    …
    <button data-stack-open="#share">Share</button>
  </main>

  <section id="share" data-stack-sheet aria-label="Share">
    <button data-stack-open="#albums">Add to album…</button>
    <button data-stack-close>Done</button>
  </section>

  <section id="albums" data-stack-sheet aria-label="Albums">
    …
    <button data-stack-close>Done</button>
  </section>
</body>