Undo Snackbar
Archive something and it folds out of the list while a snackbar springs up with an Undo button and a countdown bar that shrinks as you decide. Press Undo in time and the item slides back into place; let the bar run out and it’s gone.
- ๐Team lunch on Friday
- ๐งพYour invoice is ready
- ๐ฌNew comment on Q3 plan
How to use it
- Paste the snippet just before
</head>. - Add
data-undo-snackbar="Your message"to the button that does the action. - Add
data-undo-hide="li"(any selector) to fold away the closest matching element, such as the list row. - Do the real work on the button’s
commitevent, which fires once the time runs out. Pressing Undo firesundoinstead.
Options
Add any of these attributes to the same element.
data-duration | Time to decide. Default 5000 ms. |
data-undo-label | The button text. Default “Undo”. |
data-undo-return | Bring a removed item back after this many ms, for demos. |
data-snackbar-container | On a wrapping element: show the snackbar inside it instead of at the bottom of the page. |
data-autoplay | On that wrapper: it archives each item in turn and presses Undo by itself while in view, until someone touches it. |
The countdown pauses while the pointer is over the snackbar or focus is inside it, and a new snackbar settles the one before it. From code: animacious.undoSnackbar('Archived', { onUndo, onCommit, duration: 6000 }).
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Undo Snackbar: a snackbar springs up with an Undo button and a countdown
// bar that shrinks while you decide. Press Undo in time and the item comes
// back; let the bar run out and the action goes through.
// Usage:
// <li>Invoice.pdf
// <button data-undo-snackbar="File deleted" data-undo-hide="li">Delete</button>
// </li>
// Clicking the button folds away its closest data-undo-hide match (any CSS
// selector) and shows the snackbar. Undo unfolds it again; when time runs
// out it is removed for good.
// Events on the button: "undo" when Undo is pressed, "commit" when time runs
// out (do the real work there).
// Optional on the button: data-duration (ms to decide, default 5000),
// data-undo-label (button text, default "Undo"), data-undo-return (ms: bring
// a removed item back after this long, for demos).
// Contained: put data-snackbar-container on a wrapper to show the snackbar
// inside it (give it position: relative). On that wrapper, data-autoplay
// presses the buttons inside in turn, and Undo, by itself while in view
// until someone touches it.
// Script use: animacious.undoSnackbar('Archived', { duration, container, label, onUndo, onCommit }).
var DEFAULTS = { duration: 5000, label: 'Undo', restored: 'Restored' };
var css =
'.usn{position:fixed;left:50%;bottom:20px;z-index:2147482000;display:flex;align-items:center;gap:14px;box-sizing:border-box;width:max-content;min-width:min(300px,calc(100% - 24px));max-width:calc(100% - 24px);' +
'padding:12px 12px 15px 18px;border-radius:14px;background:#17141f;color:#fff;box-shadow:0 14px 38px rgba(14,10,28,.28);font:500 14px/1.35 system-ui,sans-serif;overflow:hidden;margin-left:0;transform:translateX(-50%)}' +
'.usn.usn-c{position:absolute;bottom:12px}' +
'.usn-msg{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
'.usn-btn{flex:none;font:inherit;font-weight:700;color:#d8b4fe;background:none;border:0;padding:6px 10px;margin:-6px 0;border-radius:8px;cursor:pointer;letter-spacing:.02em}' +
'.usn-btn:hover{background:rgba(216,180,254,.12)}' +
'.usn-btn:focus-visible{outline:2px solid #d8b4fe;outline-offset:1px}' +
'.usn-bar{position:absolute;left:0;bottom:0;width:100%;height:3px;background:linear-gradient(90deg,#a855f7,#ec4899);transform-origin:0 50%}' +
'.usn-tick{display:inline-block;margin-right:8px;color:#86efac}' +
'.usn-fold{overflow:hidden}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
var current = new Map(); // container (or body) -> live snackbar
function fold(item, done) {
if (!item) return done && done();
item._usnH = item.offsetHeight;
item.classList.add('usn-fold');
var cs = getComputedStyle(item);
item._usnM = [cs.marginTop, cs.marginBottom, cs.paddingTop, cs.paddingBottom];
if (reduced()) { item.style.display = 'none'; return done && done(); }
var tl = anime.createTimeline({ onComplete: function () { item.style.display = 'none'; if (done) done(); } });
tl.add(item, { x: [0, -40], opacity: 0, duration: 240, ease: 'in(2)' })
.add(item, { height: [item._usnH, 0], marginTop: 0, marginBottom: 0, paddingTop: 0, paddingBottom: 0, duration: 260, ease: 'inOut(2)' }, '-=60');
}
function unfold(item) {
if (!item) return;
anime.utils.remove(item);
item.style.display = '';
var m = item._usnM || ['', '', '', ''];
var clear = function () {
item.classList.remove('usn-fold');
['height', 'marginTop', 'marginBottom', 'paddingTop', 'paddingBottom', 'opacity', 'transform'].forEach(function (p) { item.style[p] = ''; });
};
if (reduced()) return clear();
var tl = anime.createTimeline({ onComplete: clear });
tl.add(item, { height: [0, item._usnH], marginTop: m[0], marginBottom: m[1], paddingTop: m[2], paddingBottom: m[3], duration: 280, ease: 'out(3)' })
.add(item, { x: [-40, 0], opacity: [0, 1], duration: 420, ease: 'outBack(1.6)' }, '-=120');
}
function leave(s) {
if (s.gone) return;
s.gone = true;
if (current.get(s.key) === s) current.delete(s.key);
if (s.el.contains(document.activeElement)) document.activeElement.blur();
if (reduced()) return s.el.remove();
anime.animate(s.el, { y: [0, 30], opacity: 0, duration: 260, ease: 'in(2)', onComplete: function () { s.el.remove(); } });
}
function commit(s) {
if (s.settled) return;
s.settled = true;
if (s.timer) s.timer.pause();
clearTimeout(s.rmTimer);
if (s.onCommit) s.onCommit();
leave(s);
}
function undo(s) {
if (s.settled) return;
s.settled = true;
if (s.timer) s.timer.pause();
clearTimeout(s.rmTimer);
if (s.onUndo) s.onUndo();
// Say so briefly, then go.
s.msg.innerHTML = '<span class="usn-tick" aria-hidden="true">✓</span>' + DEFAULTS.restored;
s.btn.style.visibility = 'hidden';
if (!reduced()) {
anime.animate(s.bar, { scaleX: 0, duration: 200, ease: 'out(2)' });
anime.animate(s.msg, { opacity: [0, 1], x: [-8, 0], duration: 260, ease: 'out(3)' });
}
setTimeout(function () { leave(s); }, 900);
}
function show(message, opts) {
opts = opts || {};
var container = opts.container || null;
var key = container || document.body;
var prev = current.get(key);
if (prev) commit(prev);
var duration = opts.duration || DEFAULTS.duration;
var el = document.createElement('div');
el.className = 'usn' + (container ? ' usn-c' : '');
el.setAttribute('role', 'status');
var msg = document.createElement('span');
msg.className = 'usn-msg';
msg.textContent = message;
// Inside a decorative preview (aria-hidden), a span stands in for the button.
var deco = container && container.closest('[aria-hidden="true"]');
var btn = document.createElement(deco ? 'span' : 'button');
if (!deco) btn.type = 'button';
btn.className = 'usn-btn';
btn.textContent = opts.label || DEFAULTS.label;
var bar = document.createElement('span');
bar.className = 'usn-bar';
bar.setAttribute('aria-hidden', 'true');
el.appendChild(msg);
el.appendChild(btn);
el.appendChild(bar);
if (container && getComputedStyle(container).position === 'static') container.style.position = 'relative';
key.appendChild(el);
var s = { el: el, msg: msg, btn: btn, bar: bar, key: key, onUndo: opts.onUndo, onCommit: opts.onCommit };
current.set(key, s);
btn.addEventListener('click', function () { undo(s); });
if (reduced()) {
s.rmTimer = setTimeout(function () { commit(s); }, duration);
return s;
}
// Keep the centring transform while anime moves it on y.
anime.utils.set(el, { x: '-50%' });
anime.animate(el, { y: [60, 0], opacity: { to: [0, 1], duration: 180, ease: 'linear' }, ease: anime.spring({ bounce: 0.3, duration: 560 }) });
anime.animate([msg, btn], { opacity: [0, 1], y: [8, 0], duration: 400, delay: anime.stagger(70, { start: 120 }), ease: 'out(3)' });
s.timer = anime.animate(bar, { scaleX: [1, 0], duration: duration, ease: 'linear', onComplete: function () { commit(s); } });
var pause = function () { if (!s.settled) s.timer.pause(); };
var resume = function () { if (!s.settled && !el.matches(':hover') && !el.contains(document.activeElement)) s.timer.play(); };
el.addEventListener('mouseenter', pause);
el.addEventListener('mouseleave', resume);
el.addEventListener('focusin', pause);
el.addEventListener('focusout', function () { setTimeout(resume, 0); });
return s;
}
function trigger(t) {
var d = t.dataset;
var item = d.undoHide ? t.closest(d.undoHide) : null;
if (item && item.classList.contains('usn-fold')) return;
var container = t.closest('[data-snackbar-container]');
fold(item);
return show(d.undoSnackbar || 'Done', {
duration: +d.duration || DEFAULTS.duration,
label: d.undoLabel,
container: container || null,
onUndo: function () {
unfold(item);
t.dispatchEvent(new CustomEvent('undo', { bubbles: true }));
},
onCommit: function () {
t.dispatchEvent(new CustomEvent('commit', { bubbles: true }));
if (!item) return;
if (d.undoReturn !== undefined) setTimeout(function () { unfold(item); }, +d.undoReturn || 1500);
else item.remove();
}
});
}
document.addEventListener('click', function (e) {
var t = e.target.closest && e.target.closest('[data-undo-snackbar]');
if (t) trigger(t);
});
// Demo mode: press each button in turn, then Undo part-way through.
function autoplay(box) {
var timer = null, visible = false, i = 0, live = true;
function step() {
clearTimeout(timer);
if (!visible || !live) return;
var all = box.querySelectorAll('[data-undo-snackbar]');
if (!all.length) return;
var t = all[i++ % all.length];
var dur = +t.dataset.duration || DEFAULTS.duration;
var s = trigger(t);
if (!s) { timer = setTimeout(step, 600); return; }
timer = setTimeout(function () {
if (!live) return;
anime.animate(s.btn, { scale: [1, 0.88, 1], duration: 260, ease: 'out(2)' });
undo(s);
timer = setTimeout(step, 2000);
}, Math.min(dur * 0.55, 3000));
}
box.addEventListener('pointerdown', function (e) { if (e.isTrusted) { live = false; clearTimeout(timer); } });
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) timer = setTimeout(step, 700);
else clearTimeout(timer);
}, { threshold: 0.4 }).observe(box);
}
function init() {
if (reduced()) return;
document.querySelectorAll('[data-snackbar-container][data-autoplay]').forEach(function (box) {
if (box._usn || box.dataset.autoplay === 'false') return;
box._usn = true;
autoplay(box);
});
}
window.animacious = window.animacious || {};
window.animacious.undoSnackbar = show;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><ul>
<li>Invoice.pdf
<button data-undo-snackbar="File deleted"
data-undo-hide="li">Delete</button>
</li>
</ul>
<script>
document.addEventListener('commit', e => {
// e.target is the button: delete for real here
});
</script>