/* 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 .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);
});
})();
/* 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.