Open demo

CSS code

HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <symbol id="i-ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 12.5 4 4 8-9"/></symbol>
  <symbol id="i-error" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v6M12 17h.01"/></symbol>
  <symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/></symbol>
</svg>

<div><span class="lbl">Stack in a corner, auto-dismiss, pause on hover and focus, undo</span>
<div class="stage">
  <div class="stage-bar"><b>Inbox</b>
    <button class="stage-btn" type="button" data-toast-show="success" data-title="Changes saved" data-text="The pricing page is live on fernhill.co.">Save changes</button>
    <button class="stage-btn" type="button" data-toast-show="undo" data-title="Conversation archived" data-text="Maya Okafor · Invoice question">Archive</button>
    <button class="stage-btn" type="button" data-toast-show="error" data-title="Export failed" data-text="The report has over 50,000 rows. Try a shorter date range.">Export CSV</button>
  </div>
  <div class="stage-rows" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></div>

  <section aria-label="Notifications">
  <div class="tst-stack" aria-live="polite" data-toast-stack>
    <div class="tst" data-toast>
      <span class="tst-icon" aria-hidden="true"><svg><use href="#i-ok"/></svg></span>
      <div class="tst-body"><p class="tst-title" data-title>Changes saved</p><p class="tst-text" data-text>The pricing page is live on fernhill.co.</p></div>
      <button class="tst-close" type="button" aria-label="Dismiss notification" data-toast-close><svg aria-hidden="true"><use href="#i-x"/></svg></button>
      <span class="tst-bar" style="--tst-time:7s" aria-hidden="true"></span>
    </div>
    <div class="tst tst-undo" data-toast>
      <div class="tst-body"><p class="tst-title" data-title>Conversation archived</p><p class="tst-text" data-text>Maya Okafor · Invoice question</p></div>
      <button class="tst-action" type="button" data-toast-undo>Undo</button>
      <button class="tst-close" type="button" aria-label="Dismiss notification" data-toast-close><svg aria-hidden="true"><use href="#i-x"/></svg></button>
      <span class="tst-bar" style="--tst-time:10s" aria-hidden="true"></span>
    </div>
    <div class="tst tst-error" role="alert" data-toast>
      <span class="tst-icon" aria-hidden="true"><svg><use href="#i-error"/></svg></span>
      <div class="tst-body"><p class="tst-title" data-title>Export failed</p><p class="tst-text" data-text>The report has over 50,000 rows. Try a shorter date range.</p>
        <div class="tst-actions"><button class="tst-action" type="button" data-toast-retry>Try again</button></div></div>
      <button class="tst-close" type="button" aria-label="Dismiss notification" data-toast-close><svg aria-hidden="true"><use href="#i-x"/></svg></button>
    </div>
  </div>
  </section>

  <!-- Templates the script clones. Errors have no timer: they stay until dismissed. -->
  <template data-toast-template="success"><div class="tst" data-toast><span class="tst-icon" aria-hidden="true"><svg><use href="#i-ok"/></svg></span><div class="tst-body"><p class="tst-title" data-title></p><p class="tst-text" data-text></p></div><button class="tst-close" type="button" aria-label="Dismiss notification" data-toast-close><svg aria-hidden="true"><use href="#i-x"/></svg></button><span class="tst-bar" style="--tst-time:6s" aria-hidden="true"></span></div></template>
  <template data-toast-template="undo"><div class="tst tst-undo" data-toast><div class="tst-body"><p class="tst-title" data-title></p><p class="tst-text" data-text></p></div><button class="tst-action" type="button" data-toast-undo>Undo</button><button class="tst-close" type="button" aria-label="Dismiss notification" data-toast-close><svg aria-hidden="true"><use href="#i-x"/></svg></button><span class="tst-bar" style="--tst-time:10s" aria-hidden="true"></span></div></template>
  <template data-toast-template="error"><div class="tst tst-error" role="alert" data-toast><span class="tst-icon" aria-hidden="true"><svg><use href="#i-error"/></svg></span><div class="tst-body"><p class="tst-title" data-title></p><p class="tst-text" data-text></p><div class="tst-actions"><button class="tst-action" type="button" data-toast-retry>Try again</button></div></div><button class="tst-close" type="button" aria-label="Dismiss notification" data-toast-close><svg aria-hidden="true"><use href="#i-x"/></svg></button></div></template>
</div></div>
CSS
/* Toast stack: a polite live region fixed to the bottom-right corner. Newest toast at the bottom. */
.tst-stack{--tst-gap:10px;position:fixed;right:16px;bottom:16px;z-index:60;display:flex;flex-direction:column;justify-content:flex-end;gap:var(--tst-gap);
  width:min(372px,calc(100% - 32px));margin:0;padding:0;list-style:none}
.tst{--tst-accent:#16a34a;--tst-soft:#dcfce7;position:relative;display:flex;align-items:flex-start;gap:12px;padding:14px 12px 16px 14px;overflow:hidden;
  background:#fff;color:#475569;border:1px solid #e2e8f0;border-radius:14px;box-shadow:0 18px 36px -18px rgba(15,23,42,.35),0 2px 6px rgba(15,23,42,.06);
  font-size:.875rem;line-height:1.45;animation:tst-in .28s cubic-bezier(.2,.8,.2,1)}
.tst-error{--tst-accent:#dc2626;--tst-soft:#fee2e2}
.tst-icon{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;background:var(--tst-soft);color:var(--tst-accent)}
.tst-icon svg{width:18px;height:18px}
.tst-body{flex:1;min-width:0}
.tst-title{margin:0;font-size:.9375rem;font-weight:650;color:#0f172a}
.tst-text{margin:2px 0 0}
.tst-actions{display:flex;gap:8px;margin-top:10px}
.tst-action{height:32px;padding:0 12px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;color:#0f172a;font:inherit;font-size:.8125rem;font-weight:650;cursor:pointer;transition:background-color .15s}
.tst-action:hover{background:#f1f5f9}
.tst-close{flex:none;display:grid;place-items:center;width:28px;height:28px;margin:-2px -2px 0 0;padding:0;border:0;border-radius:7px;background:transparent;color:#64748b;cursor:pointer;transition:background-color .15s,color .15s}
.tst-close:hover{background:#f1f5f9;color:#0f172a}
.tst-close svg{width:15px;height:15px}
.tst-close:focus-visible,.tst-action:focus-visible{outline:2px solid #2563eb;outline-offset:2px}

/* Undo toast: dark, so it reads as a reversible action rather than a status */
.tst-undo{--tst-accent:#a5b4fc;align-items:center;padding:12px 10px 14px 16px;background:#0f172a;color:#cbd5e1;border-color:#0f172a}
.tst-undo .tst-title{color:#fff}
.tst-undo .tst-action{height:34px;border:0;background:transparent;color:#c7d2fe}
.tst-undo .tst-action:hover{background:rgba(255,255,255,.1)}
.tst-undo .tst-close{color:#cbd5e1;margin:0}
.tst-undo .tst-close:hover{background:rgba(255,255,255,.1);color:#fff}
.tst-undo .tst-close:focus-visible,.tst-undo .tst-action:focus-visible{outline-color:#a5b4fc}

/* Time line: its animation IS the timer. The script dismisses on animationend, so pausing the
   animation (hover or keyboard focus anywhere in the stack) pauses the countdown too. */
.tst-bar{position:absolute;left:0;right:0;bottom:0;height:3px;background:var(--tst-accent);transform-origin:left;animation:tst-time var(--tst-time,6s) linear forwards}
.tst-stack:hover .tst-bar,.tst-stack:focus-within .tst-bar{animation-play-state:paused}
@keyframes tst-in{from{opacity:0;transform:translateY(14px) scale(.97)}}
@keyframes tst-time{to{transform:scaleX(0)}}
@media (max-width:480px){.tst-stack{right:12px;bottom:12px;width:calc(100% - 24px)}}
/* Reduced motion: no slide-in, and the line is hidden (it still times the toast). */
@media (prefers-reduced-motion:reduce){.tst{animation:none}.tst-bar{opacity:0}.tst-action,.tst-close{transition:none}}
JavaScript
(function () {
  var stack = document.querySelector('[data-toast-stack]');
  if (!stack) return;
  var MAX = 4, lastTrigger = null;
  var reduce = matchMedia('(prefers-reduced-motion: reduce)');

  function arm(toast) {
    var bar = toast.querySelector('.tst-bar');
    if (bar) bar.addEventListener('animationend', function () { dismiss(toast); });
  }

  function dismiss(toast) {
    if (!toast.isConnected || toast.dataset.leaving) return;
    toast.dataset.leaving = 'true';
    // If focus was inside, hand it to the next toast or back to the button that opened it.
    var hadFocus = toast.contains(document.activeElement);
    var sibling = toast.nextElementSibling || toast.previousElementSibling;
    var done = function () {
      toast.remove();
      if (!hadFocus) return;
      var target = (sibling && sibling.querySelector('button')) || lastTrigger;
      if (target) target.focus();
    };
    if (reduce.matches || !toast.animate) return done();
    toast.animate([{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateX(24px)' }],
      { duration: 200, easing: 'ease-in' }).onfinish = done;
  }

  function showToast(type, title, text) {
    var tpl = document.querySelector('[data-toast-template="' + type + '"]');
    if (!tpl) return;
    var toast = tpl.content.firstElementChild.cloneNode(true);
    toast.querySelector('[data-title]').textContent = title;
    toast.querySelector('[data-text]').textContent = text;
    stack.appendChild(toast);
    arm(toast);
    var toasts = stack.querySelectorAll('[data-toast]:not([data-leaving])');
    if (toasts.length > MAX) dismiss(toasts[0]);
  }

  stack.querySelectorAll('[data-toast]').forEach(arm);

  stack.addEventListener('click', function (e) {
    var toast = e.target.closest('[data-toast]');
    if (!toast) return;
    if (e.target.closest('[data-toast-close]')) dismiss(toast);
    if (e.target.closest('[data-toast-undo]')) { dismiss(toast); showToast('success', 'Conversation restored', 'It is back in your inbox.'); }
    if (e.target.closest('[data-toast-retry]')) { dismiss(toast); showToast('success', 'Export started', 'We will email the CSV to you in a few minutes.'); }
  });

  // Esc dismisses the toast that has focus.
  stack.addEventListener('keydown', function (e) {
    var toast = e.target.closest('[data-toast]');
    if (e.key === 'Escape' && toast) dismiss(toast);
  });

  document.addEventListener('click', function (e) {
    var btn = e.target.closest('[data-toast-show]');
    if (!btn) return;
    lastTrigger = btn;
    showToast(btn.dataset.toastShow, btn.dataset.title, btn.dataset.text);
  });
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS, Bootstrap 5.3.8 JS bundle

HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <symbol id="i-ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 12.5 4 4 8-9"/></symbol>
  <symbol id="i-error" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v6M12 17h.01"/></symbol>
</svg>

<div><span class="lbl">Stack in a corner, auto-dismiss, pause on hover and focus, undo</span>
<div class="stage">
  <div class="stage-bar"><b>Inbox</b>
    <button class="btn stage-btn" type="button" data-toast-show="success" data-title="Changes saved" data-text="The pricing page is live on fernhill.co.">Save changes</button>
    <button class="btn stage-btn" type="button" data-toast-show="undo" data-title="Conversation archived" data-text="Maya Okafor · Invoice question">Archive</button>
    <button class="btn stage-btn" type="button" data-toast-show="error" data-title="Export failed" data-text="The report has over 50,000 rows. Try a shorter date range.">Export CSV</button>
  </div>
  <div class="stage-rows" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></div>

  <section aria-label="Notifications">
  <div class="toast-container tst-stack" aria-live="polite" data-toast-stack>
    <div class="toast tst fade show" data-bs-autohide="false" data-toast>
      <div class="toast-body"><span class="tst-icon" aria-hidden="true"><svg><use href="#i-ok"/></svg></span>
        <div class="flex-grow-1 tst-body"><p class="tst-title" data-title>Changes saved</p><p class="tst-text" data-text>The pricing page is live on fernhill.co.</p></div>
        <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>
      <span class="tst-bar" style="--tst-time:7s" aria-hidden="true"></span>
    </div>
    <div class="toast tst tst-undo fade show" data-bs-autohide="false" data-toast>
      <div class="toast-body"><div class="flex-grow-1 tst-body"><p class="tst-title" data-title>Conversation archived</p><p class="tst-text" data-text>Maya Okafor · Invoice question</p></div>
        <button class="btn" type="button" data-toast-undo>Undo</button>
        <button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>
      <span class="tst-bar" style="--tst-time:10s" aria-hidden="true"></span>
    </div>
    <div class="toast tst tst-error fade show" role="alert" data-bs-autohide="false" data-toast>
      <div class="toast-body"><span class="tst-icon" aria-hidden="true"><svg><use href="#i-error"/></svg></span>
        <div class="flex-grow-1 tst-body"><p class="tst-title" data-title>Export failed</p><p class="tst-text" data-text>The report has over 50,000 rows. Try a shorter date range.</p>
          <div class="tst-actions"><button class="btn" type="button" data-toast-retry>Try again</button></div></div>
        <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>
    </div>
  </div>
  </section>

  <!-- Templates the script clones. Errors have no timer: they stay until dismissed. -->
  <template data-toast-template="success"><div class="toast tst fade" data-bs-autohide="false" data-toast><div class="toast-body"><span class="tst-icon" aria-hidden="true"><svg><use href="#i-ok"/></svg></span><div class="flex-grow-1 tst-body"><p class="tst-title" data-title></p><p class="tst-text" data-text></p></div><button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div><span class="tst-bar" style="--tst-time:6s" aria-hidden="true"></span></div></template>
  <template data-toast-template="undo"><div class="toast tst tst-undo fade" data-bs-autohide="false" data-toast><div class="toast-body"><div class="flex-grow-1 tst-body"><p class="tst-title" data-title></p><p class="tst-text" data-text></p></div><button class="btn" type="button" data-toast-undo>Undo</button><button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div><span class="tst-bar" style="--tst-time:10s" aria-hidden="true"></span></div></template>
  <template data-toast-template="error"><div class="toast tst tst-error fade" role="alert" data-bs-autohide="false" data-toast><div class="toast-body"><span class="tst-icon" aria-hidden="true"><svg><use href="#i-error"/></svg></span><div class="flex-grow-1 tst-body"><p class="tst-title" data-title></p><p class="tst-text" data-text></p><div class="tst-actions"><button class="btn" type="button" data-toast-retry>Try again</button></div></div><button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div></div></template>
</div></div>
CSS
/* Bootstrap .toast-container + .toast, shown and hidden with the Toast plugin (autohide off: the time line below is the timer). */
.tst-stack{position:fixed;right:16px;bottom:16px;z-index:1090;display:flex;flex-direction:column;justify-content:flex-end;gap:10px;
  width:min(372px,calc(100% - 32px));margin:0;padding:0;list-style:none;--bs-toast-spacing:0}
.tst-stack>:not(:last-child){margin-bottom:0}
.tst{--bs-toast-max-width:100%;--bs-toast-padding-x:14px;--bs-toast-padding-y:14px;--bs-toast-bg:#fff;--bs-toast-color:#475569;--bs-toast-border-color:#e2e8f0;
  --bs-toast-border-radius:14px;--bs-toast-font-size:.875rem;--bs-toast-box-shadow:0 18px 36px -18px rgba(15,23,42,.35),0 2px 6px rgba(15,23,42,.06);
  --tst-accent:#16a34a;--tst-soft:#dcfce7;position:relative;overflow:hidden;line-height:1.45}
.tst.show{animation:tst-in .28s cubic-bezier(.2,.8,.2,1)}
.tst-error{--tst-accent:#dc2626;--tst-soft:#fee2e2}
.tst .toast-body{display:flex;align-items:flex-start;gap:12px;padding:14px 12px 16px 14px}
.tst-icon{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;background:var(--tst-soft);color:var(--tst-accent)}
.tst-icon svg{width:18px;height:18px}
.tst-body{min-width:0}
.tst-title{margin:0;font-size:.9375rem;font-weight:650;color:#0f172a}
.tst-text{margin:2px 0 0}
.tst-actions{display:flex;gap:8px;margin-top:10px}
.tst .btn{--bs-btn-padding-x:12px;--bs-btn-font-size:.8125rem;--bs-btn-font-weight:650;--bs-btn-border-radius:8px;--bs-btn-color:#0f172a;--bs-btn-bg:#fff;--bs-btn-border-color:#cbd5e1;
  --bs-btn-hover-color:#0f172a;--bs-btn-hover-bg:#f1f5f9;--bs-btn-hover-border-color:#cbd5e1;--bs-btn-active-bg:#e2e8f0;--bs-btn-focus-box-shadow:0 0 0 2px #fff,0 0 0 4px #2563eb;height:32px}
.tst .btn-close{--bs-btn-close-opacity:.6;--bs-btn-close-hover-opacity:1;--bs-btn-close-focus-shadow:0 0 0 2px #2563eb;flex:none;width:28px;height:28px;margin:-2px -2px 0 0;padding:0;border-radius:7px;background-size:10px}
.tst .btn-close:hover{background-color:#f1f5f9}

/* Undo toast */
.tst-undo{--bs-toast-bg:#0f172a;--bs-toast-color:#cbd5e1;--bs-toast-border-color:#0f172a;--tst-accent:#a5b4fc}
.tst-undo .toast-body{align-items:center;padding:12px 10px 14px 16px}
.tst-undo .tst-title{color:#fff}
.tst-undo .btn{--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-color:#c7d2fe;--bs-btn-hover-bg:rgba(255,255,255,.1);--bs-btn-hover-border-color:transparent;--bs-btn-hover-color:#c7d2fe;
  --bs-btn-active-bg:rgba(255,255,255,.14);--bs-btn-active-color:#c7d2fe;--bs-btn-focus-box-shadow:0 0 0 2px #a5b4fc;height:34px}
.tst-undo .btn-close{margin:0;--bs-btn-close-opacity:.8;--bs-btn-close-focus-shadow:0 0 0 2px #a5b4fc}
.tst-undo .btn-close:hover{background-color:rgba(255,255,255,.1)}

/* Time line: dismissal happens on animationend, so pausing it on hover or focus pauses the countdown. */
.tst-bar{position:absolute;left:0;right:0;bottom:0;height:3px;background:var(--tst-accent);transform-origin:left;animation:tst-time var(--tst-time,6s) linear forwards}
.tst-stack:hover .tst-bar,.tst-stack:focus-within .tst-bar{animation-play-state:paused}
@keyframes tst-in{from{opacity:0;transform:translateY(14px) scale(.97)}}
@keyframes tst-time{to{transform:scaleX(0)}}
@media (max-width:480px){.tst-stack{right:12px;bottom:12px;width:calc(100% - 24px)}}
@media (prefers-reduced-motion:reduce){.tst.show{animation:none}.tst-bar{opacity:0}.tst.fade{transition:none}}
JavaScript
(function () {
  var stack = document.querySelector('[data-toast-stack]');
  if (!stack) return;
  var MAX = 4, lastTrigger = null;

  function arm(toast) {
    var bar = toast.querySelector('.tst-bar');
    if (bar) bar.addEventListener('animationend', function () { dismiss(toast); });
    var hadFocus = false, sibling = null;
    toast.addEventListener('hide.bs.toast', function () {
      toast.dataset.leaving = 'true';
      hadFocus = toast.contains(document.activeElement);
      sibling = toast.nextElementSibling || toast.previousElementSibling;
    });
    // Remove the hidden toast, and hand focus to the next toast or back to the button that opened it.
    toast.addEventListener('hidden.bs.toast', function () {
      toast.remove();
      if (!hadFocus) return;
      var target = (sibling && sibling.querySelector('button')) || lastTrigger;
      if (target) target.focus();
    });
  }

  function dismiss(toast) {
    if (toast.isConnected && !toast.dataset.leaving) bootstrap.Toast.getOrCreateInstance(toast).hide();
  }

  function showToast(type, title, text) {
    var tpl = document.querySelector('[data-toast-template="' + type + '"]');
    if (!tpl) return;
    var toast = tpl.content.firstElementChild.cloneNode(true);
    toast.querySelector('[data-title]').textContent = title;
    toast.querySelector('[data-text]').textContent = text;
    stack.appendChild(toast);
    arm(toast);
    bootstrap.Toast.getOrCreateInstance(toast).show();
    var toasts = stack.querySelectorAll('[data-toast]:not([data-leaving])');
    if (toasts.length > MAX) dismiss(toasts[0]);
  }

  stack.querySelectorAll('[data-toast]').forEach(arm);

  stack.addEventListener('click', function (e) {
    var toast = e.target.closest('[data-toast]');
    if (!toast) return;
    if (e.target.closest('[data-toast-undo]')) { dismiss(toast); showToast('success', 'Conversation restored', 'It is back in your inbox.'); }
    if (e.target.closest('[data-toast-retry]')) { dismiss(toast); showToast('success', 'Export started', 'We will email the CSV to you in a few minutes.'); }
  });

  // Esc dismisses the toast that has focus.
  stack.addEventListener('keydown', function (e) {
    var toast = e.target.closest('[data-toast]');
    if (e.key === 'Escape' && toast) dismiss(toast);
  });

  document.addEventListener('click', function (e) {
    var btn = e.target.closest('[data-toast-show]');
    if (!btn) return;
    lastTrigger = btn;
    showToast(btn.dataset.toastShow, btn.dataset.title, btn.dataset.text);
  });
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <symbol id="i-ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 12.5 4 4 8-9"/></symbol>
  <symbol id="i-error" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v6M12 17h.01"/></symbol>
  <symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/></symbol>
</svg>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#5b6474] uppercase">Stack in a corner, auto-dismiss, pause on hover and focus, undo</span>
<div class="stage relative h-[500px] overflow-hidden rounded-2xl border border-[#d6dce5] bg-[#f8fafc] shadow-[0_1px_2px_rgba(15,23,42,.05),0_18px_40px_-22px_rgba(15,23,42,.28)]">
  <div class="flex flex-wrap items-center gap-2 border-b border-[#e2e8f0] bg-white px-[18px] py-3.5"><b class="mr-auto text-[#0f172a]">Inbox</b>
    <button class="h-9 cursor-pointer rounded-[9px] border border-[#cbd5e1] bg-white px-3.5 text-sm font-semibold text-[#0f172a] hover:bg-[#f1f5f9] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb]" type="button" data-toast-show="success" data-title="Changes saved" data-text="The pricing page is live on fernhill.co.">Save changes</button>
    <button class="h-9 cursor-pointer rounded-[9px] border border-[#cbd5e1] bg-white px-3.5 text-sm font-semibold text-[#0f172a] hover:bg-[#f1f5f9] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb]" type="button" data-toast-show="undo" data-title="Conversation archived" data-text="Maya Okafor · Invoice question">Archive</button>
    <button class="h-9 cursor-pointer rounded-[9px] border border-[#cbd5e1] bg-white px-3.5 text-sm font-semibold text-[#0f172a] hover:bg-[#f1f5f9] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb]" type="button" data-toast-show="error" data-title="Export failed" data-text="The report has over 50,000 rows. Try a shorter date range.">Export CSV</button>
  </div>
  <div class="p-[18px]" aria-hidden="true"><i class="mb-2.5 flex h-[54px] items-center gap-3 rounded-xl border border-[#eef1f5] bg-white px-3.5 before:size-[30px] before:rounded-full before:bg-[#e2e8f0] after:h-[9px] after:w-2/5 after:rounded-full after:bg-[#e7ebf1]"></i><i class="mb-2.5 flex h-[54px] items-center gap-3 rounded-xl border border-[#eef1f5] bg-white px-3.5 before:size-[30px] before:rounded-full before:bg-[#e2e8f0] after:h-[9px] after:w-2/5 after:rounded-full after:bg-[#e7ebf1]"></i><i class="mb-2.5 flex h-[54px] items-center gap-3 rounded-xl border border-[#eef1f5] bg-white px-3.5 before:size-[30px] before:rounded-full before:bg-[#e2e8f0] after:h-[9px] after:w-2/5 after:rounded-full after:bg-[#e7ebf1]"></i><i class="mb-2.5 flex h-[54px] items-center gap-3 rounded-xl border border-[#eef1f5] bg-white px-3.5 before:size-[30px] before:rounded-full before:bg-[#e2e8f0] after:h-[9px] after:w-2/5 after:rounded-full after:bg-[#e7ebf1]"></i><i class="mb-2.5 flex h-[54px] items-center gap-3 rounded-xl border border-[#eef1f5] bg-white px-3.5 before:size-[30px] before:rounded-full before:bg-[#e2e8f0] after:h-[9px] after:w-2/5 after:rounded-full after:bg-[#e7ebf1]"></i></div>

  <section aria-label="Notifications">
  <div class="group/stack fixed right-4 bottom-4 z-60 flex w-[min(372px,calc(100%-32px))] flex-col justify-end gap-2.5 max-[480px]:right-3 max-[480px]:bottom-3 max-[480px]:w-[calc(100%-24px)]" aria-live="polite" data-toast-stack>
    <div class="relative flex animate-toast-in items-start gap-3 overflow-hidden rounded-[14px] border border-[#e2e8f0] bg-white pt-3.5 pr-3 pb-4 pl-3.5 text-sm leading-[1.45] text-[#475569] shadow-[0_18px_36px_-18px_rgba(15,23,42,.35),0_2px_6px_rgba(15,23,42,.06)] motion-reduce:animate-none" data-toast>
      <span class="grid size-8 flex-none place-items-center rounded-full bg-[#dcfce7] text-[#16a34a]" aria-hidden="true"><svg class="size-[18px]"><use href="#i-ok"/></svg></span>
      <div class="min-w-0 flex-1"><p class="text-[.9375rem] font-[650] text-[#0f172a]" data-title>Changes saved</p><p class="mt-0.5" data-text>The pricing page is live on fernhill.co.</p></div>
      <button class="-mt-0.5 -mr-0.5 grid size-7 flex-none cursor-pointer place-items-center rounded-[7px] text-[#64748b] transition-colors duration-150 hover:bg-[#f1f5f9] hover:text-[#0f172a] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb] motion-reduce:transition-none" type="button" aria-label="Dismiss notification" data-toast-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button>
      <span class="absolute inset-x-0 bottom-0 h-[3px] origin-left animate-toast-time bg-[#16a34a] group-hover/stack:[animation-play-state:paused] group-focus-within/stack:[animation-play-state:paused] motion-reduce:opacity-0" style="--tst-time:7s" aria-hidden="true" data-toast-bar></span>
    </div>
    <div class="relative flex animate-toast-in items-center gap-3 overflow-hidden rounded-[14px] border border-[#0f172a] bg-[#0f172a] pt-3 pr-2.5 pb-3.5 pl-4 text-sm leading-[1.45] text-[#cbd5e1] shadow-[0_18px_36px_-18px_rgba(15,23,42,.35),0_2px_6px_rgba(15,23,42,.06)] motion-reduce:animate-none" data-toast>
      <div class="min-w-0 flex-1"><p class="text-[.9375rem] font-[650] text-white" data-title>Conversation archived</p><p class="mt-0.5" data-text>Maya Okafor · Invoice question</p></div>
      <button class="h-[34px] cursor-pointer rounded-lg px-3 text-[.8125rem] font-[650] text-[#c7d2fe] transition-colors duration-150 hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#a5b4fc] motion-reduce:transition-none" type="button" data-toast-undo>Undo</button>
      <button class="grid size-7 flex-none cursor-pointer place-items-center rounded-[7px] text-[#cbd5e1] transition-colors duration-150 hover:bg-white/10 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#a5b4fc] motion-reduce:transition-none" type="button" aria-label="Dismiss notification" data-toast-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button>
      <span class="absolute inset-x-0 bottom-0 h-[3px] origin-left animate-toast-time bg-[#a5b4fc] group-hover/stack:[animation-play-state:paused] group-focus-within/stack:[animation-play-state:paused] motion-reduce:opacity-0" style="--tst-time:10s" aria-hidden="true" data-toast-bar></span>
    </div>
    <div class="relative flex animate-toast-in items-start gap-3 overflow-hidden rounded-[14px] border border-[#e2e8f0] bg-white pt-3.5 pr-3 pb-4 pl-3.5 text-sm leading-[1.45] text-[#475569] shadow-[0_18px_36px_-18px_rgba(15,23,42,.35),0_2px_6px_rgba(15,23,42,.06)] motion-reduce:animate-none" role="alert" data-toast>
      <span class="grid size-8 flex-none place-items-center rounded-full bg-[#fee2e2] text-[#dc2626]" aria-hidden="true"><svg class="size-[18px]"><use href="#i-error"/></svg></span>
      <div class="min-w-0 flex-1"><p class="text-[.9375rem] font-[650] text-[#0f172a]" data-title>Export failed</p><p class="mt-0.5" data-text>The report has over 50,000 rows. Try a shorter date range.</p>
        <div class="mt-2.5 flex gap-2"><button class="h-8 cursor-pointer rounded-lg border border-[#cbd5e1] bg-white px-3 text-[.8125rem] font-[650] text-[#0f172a] transition-colors duration-150 hover:bg-[#f1f5f9] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb] motion-reduce:transition-none" type="button" data-toast-retry>Try again</button></div></div>
      <button class="-mt-0.5 -mr-0.5 grid size-7 flex-none cursor-pointer place-items-center rounded-[7px] text-[#64748b] transition-colors duration-150 hover:bg-[#f1f5f9] hover:text-[#0f172a] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb] motion-reduce:transition-none" type="button" aria-label="Dismiss notification" data-toast-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button>
    </div>
  </div>
  </section>

  <!-- Templates the script clones (same classes as the toasts above, so Tailwind has already generated them). Errors have no timer. -->
  <template data-toast-template="success"><div class="relative flex animate-toast-in items-start gap-3 overflow-hidden rounded-[14px] border border-[#e2e8f0] bg-white pt-3.5 pr-3 pb-4 pl-3.5 text-sm leading-[1.45] text-[#475569] shadow-[0_18px_36px_-18px_rgba(15,23,42,.35),0_2px_6px_rgba(15,23,42,.06)] motion-reduce:animate-none" data-toast><span class="grid size-8 flex-none place-items-center rounded-full bg-[#dcfce7] text-[#16a34a]" aria-hidden="true"><svg class="size-[18px]"><use href="#i-ok"/></svg></span><div class="min-w-0 flex-1"><p class="text-[.9375rem] font-[650] text-[#0f172a]" data-title></p><p class="mt-0.5" data-text></p></div><button class="-mt-0.5 -mr-0.5 grid size-7 flex-none cursor-pointer place-items-center rounded-[7px] text-[#64748b] transition-colors duration-150 hover:bg-[#f1f5f9] hover:text-[#0f172a] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb] motion-reduce:transition-none" type="button" aria-label="Dismiss notification" data-toast-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button><span class="absolute inset-x-0 bottom-0 h-[3px] origin-left animate-toast-time bg-[#16a34a] group-hover/stack:[animation-play-state:paused] group-focus-within/stack:[animation-play-state:paused] motion-reduce:opacity-0" style="--tst-time:6s" aria-hidden="true" data-toast-bar></span></div></template>
  <template data-toast-template="undo"><div class="relative flex animate-toast-in items-center gap-3 overflow-hidden rounded-[14px] border border-[#0f172a] bg-[#0f172a] pt-3 pr-2.5 pb-3.5 pl-4 text-sm leading-[1.45] text-[#cbd5e1] shadow-[0_18px_36px_-18px_rgba(15,23,42,.35),0_2px_6px_rgba(15,23,42,.06)] motion-reduce:animate-none" data-toast><div class="min-w-0 flex-1"><p class="text-[.9375rem] font-[650] text-white" data-title></p><p class="mt-0.5" data-text></p></div><button class="h-[34px] cursor-pointer rounded-lg px-3 text-[.8125rem] font-[650] text-[#c7d2fe] transition-colors duration-150 hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#a5b4fc] motion-reduce:transition-none" type="button" data-toast-undo>Undo</button><button class="grid size-7 flex-none cursor-pointer place-items-center rounded-[7px] text-[#cbd5e1] transition-colors duration-150 hover:bg-white/10 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#a5b4fc] motion-reduce:transition-none" type="button" aria-label="Dismiss notification" data-toast-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button><span class="absolute inset-x-0 bottom-0 h-[3px] origin-left animate-toast-time bg-[#a5b4fc] group-hover/stack:[animation-play-state:paused] group-focus-within/stack:[animation-play-state:paused] motion-reduce:opacity-0" style="--tst-time:10s" aria-hidden="true" data-toast-bar></span></div></template>
  <template data-toast-template="error"><div class="relative flex animate-toast-in items-start gap-3 overflow-hidden rounded-[14px] border border-[#e2e8f0] bg-white pt-3.5 pr-3 pb-4 pl-3.5 text-sm leading-[1.45] text-[#475569] shadow-[0_18px_36px_-18px_rgba(15,23,42,.35),0_2px_6px_rgba(15,23,42,.06)] motion-reduce:animate-none" role="alert" data-toast><span class="grid size-8 flex-none place-items-center rounded-full bg-[#fee2e2] text-[#dc2626]" aria-hidden="true"><svg class="size-[18px]"><use href="#i-error"/></svg></span><div class="min-w-0 flex-1"><p class="text-[.9375rem] font-[650] text-[#0f172a]" data-title></p><p class="mt-0.5" data-text></p><div class="mt-2.5 flex gap-2"><button class="h-8 cursor-pointer rounded-lg border border-[#cbd5e1] bg-white px-3 text-[.8125rem] font-[650] text-[#0f172a] transition-colors duration-150 hover:bg-[#f1f5f9] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb] motion-reduce:transition-none" type="button" data-toast-retry>Try again</button></div></div><button class="-mt-0.5 -mr-0.5 grid size-7 flex-none cursor-pointer place-items-center rounded-[7px] text-[#64748b] transition-colors duration-150 hover:bg-[#f1f5f9] hover:text-[#0f172a] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2563eb] motion-reduce:transition-none" type="button" aria-label="Dismiss notification" data-toast-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button></div></template>
</div></div>
CSS
/* Slide-in, and the time line whose animation is the timer (the script dismisses on animationend). */
@theme {
  --animate-toast-in: toast-in .28s cubic-bezier(.2,.8,.2,1);
  --animate-toast-time: toast-time var(--tst-time, 6s) linear forwards;
  @keyframes toast-in { from { opacity: 0; transform: translateY(14px) scale(.97); } }
  @keyframes toast-time { to { transform: scaleX(0); } }
}
JavaScript
(function () {
  var stack = document.querySelector('[data-toast-stack]');
  if (!stack) return;
  var MAX = 4, lastTrigger = null;
  var reduce = matchMedia('(prefers-reduced-motion: reduce)');

  function arm(toast) {
    var bar = toast.querySelector('[data-toast-bar]');
    if (bar) bar.addEventListener('animationend', function () { dismiss(toast); });
  }

  function dismiss(toast) {
    if (!toast.isConnected || toast.dataset.leaving) return;
    toast.dataset.leaving = 'true';
    // If focus was inside, hand it to the next toast or back to the button that opened it.
    var hadFocus = toast.contains(document.activeElement);
    var sibling = toast.nextElementSibling || toast.previousElementSibling;
    var done = function () {
      toast.remove();
      if (!hadFocus) return;
      var target = (sibling && sibling.querySelector('button')) || lastTrigger;
      if (target) target.focus();
    };
    if (reduce.matches || !toast.animate) return done();
    toast.animate([{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateX(24px)' }],
      { duration: 200, easing: 'ease-in' }).onfinish = done;
  }

  function showToast(type, title, text) {
    var tpl = document.querySelector('[data-toast-template="' + type + '"]');
    if (!tpl) return;
    var toast = tpl.content.firstElementChild.cloneNode(true);
    toast.querySelector('[data-title]').textContent = title;
    toast.querySelector('[data-text]').textContent = text;
    stack.appendChild(toast);
    arm(toast);
    var toasts = stack.querySelectorAll('[data-toast]:not([data-leaving])');
    if (toasts.length > MAX) dismiss(toasts[0]);
  }

  stack.querySelectorAll('[data-toast]').forEach(arm);

  stack.addEventListener('click', function (e) {
    var toast = e.target.closest('[data-toast]');
    if (!toast) return;
    if (e.target.closest('[data-toast-close]')) dismiss(toast);
    if (e.target.closest('[data-toast-undo]')) { dismiss(toast); showToast('success', 'Conversation restored', 'It is back in your inbox.'); }
    if (e.target.closest('[data-toast-retry]')) { dismiss(toast); showToast('success', 'Export started', 'We will email the CSV to you in a few minutes.'); }
  });

  // Esc dismisses the toast that has focus.
  stack.addEventListener('keydown', function (e) {
    var toast = e.target.closest('[data-toast]');
    if (e.key === 'Escape' && toast) dismiss(toast);
  });

  document.addEventListener('click', function (e) {
    var btn = e.target.closest('[data-toast-show]');
    if (!btn) return;
    lastTrigger = btn;
    showToast(btn.dataset.toastShow, btn.dataset.title, btn.dataset.text);
  });
})();

About this set

A working toast stack in the bottom-right corner of an inbox. Three buttons add toasts: Save changes shows a success toast that closes itself after six seconds, Archive shows a dark toast with an Undo button that stays for ten seconds, and Export CSV shows an error toast with a Try again button that never closes on its own, because errors must not disappear before they are read. A thin line along the bottom of each timed toast shows the time left, and that line is the timer: the script dismisses the toast when the line’s CSS animation ends. Hovering the stack or tabbing into it pauses the animation, so the countdown pauses too, and it resumes where it stopped. Esc closes the focused toast, closing one hands focus to the next toast or back to the button that opened it, and the stack keeps at most four toasts. Toasts are cloned from template elements, so adding a type is a matter of markup. The stack is a polite live region and only the error toast uses role=”alert”. The Bootstrap version uses .toast-container and .toast with the Toast plugin showing and hiding them.

What’s included

  • Auto-dismiss driven by the progress line's animationend
  • Countdown pauses on hover and keyboard focus
  • Undo and Try again actions; errors never auto-dismiss
  • Esc closes the focused toast and focus is handed back
  • Templates for each toast type; stack capped at four

Accessibility

The stack is an aria-live="polite" region labelled Notifications, so new toasts are announced without interrupting, and only the error toast is role="alert". Timed toasts pause while the pointer is over the stack or focus is inside it, so people get the time they need to read a toast or reach its action, and errors wait to be dismissed. Close buttons are named, Esc closes the focused toast, and focus returns to the next toast or the triggering button. Under prefers-reduced-motion the slide-in is removed and the time line is hidden while still timing the toast.

Customise it

Set the duration per toast with --tst-time on its .tst-bar, and the status colours with --tst-accent and --tst-soft on .tst and .tst-error. The corner is set by right and bottom on .tst-stack, and MAX in the script caps the stack. In Bootstrap the card look uses --bs-toast-* variables; in Tailwind edit the utilities and the two keyframes in the @theme block.