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
- Paste the snippet just before
</head>. - Add
data-notification-stack-expandto a wrapper and put each notification inside as its own element, newest first.
Options
Add any of these attributes to the same element.
data-title | The heading above the stack. Default “Notifications”; set it empty for none. |
data-peek | How much of each card shows below the one in front. Default 10 px. |
data-gap | Space between cards once open. Default 8 px. |
data-duration | Time for the cards to settle. Default 600 ms. |
data-open | Start opened out. |
data-autoplay | Time 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).
<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><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>