← Overlays

Toast Stack

Small notifications spring in at the corner of the screen, stack neatly, show a shrinking timer bar and slide away on their own. Hover one to keep it open.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-toast="Your message" to any button. Clicking it shows the toast.

Options

Add any of these attributes to the same element.

data-toastThe message to show.
data-toast-durationHow long it stays. Default 3200 ms.
data-toast-containerOn a wrapping element: show toasts inside it instead of the page corner.

From code: animacious.toast('Saved', { duration: 4000 }).

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Toast Stack: small notifications spring in at the corner of the screen,
    // stack neatly, show a shrinking timer bar and slide away on their own.
    // Usage: <button data-toast="Link copied">Copy link</button>
    //    or from code: animacious.toast('Saved', { duration: 4000 })
    // Optional on the button: data-toast-duration (ms, default 3200).
    // Contained: add data-toast-container to an element to show toasts inside it
    // (pass { container: element } from code).
    var DEFAULTS = { duration: 3200, max: 4 };

    var css =
      '.ts-stack{position:fixed;right:20px;bottom:20px;z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:0;pointer-events:none;font:500 14px/1.4 system-ui,sans-serif}' +
      '.ts-stack.is-contained{position:absolute;right:12px;bottom:12px}' +
      '.ts-wrap{overflow:hidden}' +
      '.ts-toast{position:relative;margin-top:10px;padding:12px 16px 14px;border-radius:12px;background:#111114;color:#fff;box-shadow:0 10px 30px rgba(0,0,0,.18);pointer-events:auto;overflow:hidden;max-width:320px}' +
      '.ts-bar{position:absolute;left:0;bottom:0;height:3px;width:100%;background:#a855f7;transform-origin:0 50%}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var stacks = new Map();
    function stackFor(container) {
      var key = container || document.body;
      if (stacks.has(key)) return stacks.get(key);
      var s = document.createElement('div');
      s.className = 'ts-stack' + (container ? ' is-contained' : '');
      s.setAttribute('role', 'status');
      s.setAttribute('aria-live', 'polite');
      if (container && getComputedStyle(container).position === 'static') container.style.position = 'relative';
      key.appendChild(s);
      stacks.set(key, s);
      return s;
    }

    function dismiss(wrap) {
      if (wrap._gone) return;
      wrap._gone = true;
      var toast = wrap.firstChild;
      anime.animate(toast, { x: '115%', opacity: 0, duration: 350, ease: 'in(3)' });
      anime.animate(wrap, {
        height: '0px',
        duration: 300,
        delay: 220,
        ease: 'inOut(2)',
        onComplete: function () { wrap.remove(); }
      });
    }

    function toast(message, opts) {
      opts = opts || {};
      var stack = stackFor(opts.container);
      var duration = opts.duration || DEFAULTS.duration;
      var wrap = document.createElement('div');
      wrap.className = 'ts-wrap';
      var t = document.createElement('div');
      t.className = 'ts-toast';
      t.textContent = message;
      var bar = document.createElement('div');
      bar.className = 'ts-bar';
      t.appendChild(bar);
      wrap.appendChild(t);
      stack.appendChild(wrap);

      // Too many? Retire the oldest.
      var live = Array.from(stack.children).filter(function (w) { return !w._gone; });
      if (live.length > DEFAULTS.max) dismiss(live[0]);

      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        setTimeout(function () { wrap.remove(); }, duration);
        return;
      }
      // Open a gap for it (pushing older toasts up), then spring it in.
      var h = wrap.offsetHeight;
      anime.animate(wrap, { height: ['0px', h + 'px'], duration: 300, ease: 'out(3)' });
      anime.animate(t, { x: ['110%', '0%'], duration: 700, ease: 'outBack(1.4)' });
      var timer = anime.animate(bar, { scaleX: [1, 0], duration: duration, ease: 'linear', onComplete: function () { dismiss(wrap); } });
      t.addEventListener('mouseenter', function () { timer.pause(); });
      t.addEventListener('mouseleave', function () { timer.play(); });
      t.addEventListener('click', function () { dismiss(wrap); });
    }

    document.addEventListener('click', function (e) {
      var b = e.target.closest && e.target.closest('[data-toast]');
      if (!b) return;
      var container = b.closest('[data-toast-container]');
      toast(b.getAttribute('data-toast'), { duration: +b.dataset.toastDuration || undefined, container: container || undefined });
    });

    window.animacious = window.animacious || {};
    window.animacious.toast = toast;
  })();
</script>
2 · Your HTML
<button data-toast="Link copied">Copy link</button>