โ† Overlays

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

  1. Paste the snippet just before </head>.
  2. Add data-undo-snackbar="Your message" to the button that does the action.
  3. Add data-undo-hide="li" (any selector) to fold away the closest matching element, such as the list row.
  4. Do the real work on the button’s commit event, which fires once the time runs out. Pressing Undo fires undo instead.

Options

Add any of these attributes to the same element.

data-durationTime to decide. Default 5000 ms.
data-undo-labelThe button text. Default “Undo”.
data-undo-returnBring a removed item back after this many ms, for demos.
data-snackbar-containerOn a wrapping element: show the snackbar inside it instead of at the bottom of the page.
data-autoplayOn 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 }).

1 ยท Snippet
<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">&#10003;</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>
2 ยท Your HTML
<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>