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
Choose an album
How to use it
- Paste the snippet just before
</head>. - Add
data-stacked-sheetsto a wrapper (or<body>), anddata-stack-pageto the page content inside it. - Add
data-stack-sheetand anidto each sheet, also inside the wrapper. - Add
data-stack-open="#that-id"to the buttons that open them (including buttons inside a sheet, to stack another), anddata-stack-closeto the ones that close them.
Options
Add any of these attributes to the same element.
data-top | How much of the page shows above a sheet, in px. Default 48 (10% of the height when contained). |
data-duration | Time for a sheet to slide up. Default 560 ms. |
data-contained | Keep everything inside the wrapper, for app frames and previews. |
data-autoplay | Time 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).
<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><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>