/* Notification toast stack. The ring around the close button is the timer: its CSS animation runs for --nt-time
and the script dismisses the toast on animationend, so pausing the animation (hover, keyboard focus) pauses the countdown. */
.nt-stack,.nt-toast{--nt-brand:#4f46e5;--nt-ink:#0f172a;--nt-text:#475569;--nt-muted:#5b6474;--nt-line:#e2e8f0}
.nt-stack{position:fixed;z-index:1090;display:flex;flex-direction:column-reverse;gap:8px;width:min(380px,calc(100% - 32px));margin:0;padding:0;
top:16px;right:16px}
.nt-stack[data-pos="tc"]{right:auto;left:50%;transform:translateX(-50%)}
.nt-stack[data-pos="br"]{top:auto;bottom:16px;flex-direction:column}
.nt-stack[data-pos="bl"]{top:auto;right:auto;bottom:16px;left:16px;flex-direction:column}
.nt-toast{--nt-accent:var(--nt-brand);--nt-soft:#eef2ff;--nt-from:-12px;position:relative;display:flex;align-items:flex-start;gap:12px;padding:14px 12px 14px 14px;
background:#fff;color:var(--nt-text);border:1px solid var(--nt-line);border-radius:16px;font-size:.875rem;line-height:1.45;
box-shadow:0 20px 40px -20px rgba(15,23,42,.38),0 2px 6px rgba(15,23,42,.06);animation:nt-in .3s cubic-bezier(.2,.8,.2,1)}
.nt-stack[data-pos^="b"] .nt-toast{--nt-from:12px}
.nt-toast.is-leaving{animation:nt-out .2s ease-in forwards}
.nt-success{--nt-accent:#15803d;--nt-soft:#dcfce7}
.nt-warning{--nt-accent:#b45309;--nt-soft:#fef3c7}
.nt-error{--nt-accent:#dc2626;--nt-soft:#fee2e2;border-color:#fecaca}
.nt-icon,.nt-avatar{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:50%;background:var(--nt-soft);color:var(--nt-accent)}
.nt-icon svg{width:19px;height:19px}
.nt-avatar{background:#ddd6fe;color:#4c1d95;font-size:.8125rem;font-weight:700;letter-spacing:.02em}
.nt-body{flex:1;min-width:0}
.nt-title{margin:0;font-size:.9375rem;font-weight:600;color:var(--nt-ink)}
.nt-text{margin:2px 0 0}
.nt-meta{margin:6px 0 0;font-size:.75rem;color:var(--nt-muted)}
.nt-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.nt-act{height:32px;padding:0 12px;border:1px solid #cbd5e1;border-radius:9px;background:#fff;color:var(--nt-ink);font:inherit;font-size:.8125rem;font-weight:600;cursor:pointer}
.nt-act:hover{background:#f1f5f9}
.nt-act-primary{border-color:var(--nt-accent);background:var(--nt-accent);color:#fff}
.nt-act-primary:hover{background:var(--nt-accent);filter:brightness(.92)}
/* Close button with the timer ring around it */
.nt-close{position:relative;flex:none;display:grid;place-items:center;width:34px;height:34px;margin:-4px -2px 0 0;padding:0;border:0;border-radius:50%;
background:transparent;color:#64748b;cursor:pointer}
.nt-close:hover{background:#f1f5f9;color:var(--nt-ink)}
.nt-close .nt-x{width:14px;height:14px}
.nt-ring{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg);pointer-events:none}
.nt-ring circle{fill:none;stroke-width:2.5}
.nt-ring .nt-track{stroke:#e2e8f0}
.nt-ring .nt-time{stroke:var(--nt-accent);stroke-dasharray:100;stroke-dashoffset:0;stroke-linecap:round;animation:nt-time var(--nt-time,6s) linear forwards}
.nt-stack:hover .nt-time,.nt-stack:focus-within .nt-time{animation-play-state:paused}
.nt-act:focus-visible,.nt-close:focus-visible{outline:2px solid var(--nt-brand);outline-offset:2px}
@keyframes nt-in{from{opacity:0;transform:translateY(var(--nt-from)) scale(.97)}}
@keyframes nt-out{to{opacity:0;transform:scale(.96)}}
@keyframes nt-time{to{stroke-dashoffset:100}}
/* Static variants (no timer) for in-page use */
.nt-static{position:static;animation:none;box-shadow:0 1px 2px rgba(15,23,42,.05),0 12px 28px -20px rgba(15,23,42,.35)}
@media (max-width:480px){.nt-stack{width:calc(100% - 24px)}.nt-stack,.nt-stack[data-pos]{left:12px;right:12px;transform:none}}
@media (prefers-reduced-motion:reduce){.nt-toast,.nt-toast.is-leaving{animation:none}}
JavaScript
(function () {
var stack = document.querySelector('[data-toast-stack]');
if (!stack) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var MAX = 4, lastTrigger = null, n = 0;
var content = {
mention: [
{ avatar: 'LO', title: 'Leo Okafor replied to you', text: '“Pushed the new hero copy, take a look when you can.”', meta: 'Website launch · just now' },
{ avatar: 'MK', title: 'Mina Kovač mentioned you', text: '“@Sam the client approved option B.”', meta: 'Bramble rebrand · just now' }
],
success: [{ title: 'Invoice INV-2042 sent', text: 'Harbor Labs will get it at [email protected].' }],
error: [{ title: 'Google Drive sync failed', text: 'We couldn’t reach Drive. Your files are saved locally.' }]
};
function arm(toast) {
var timer = toast.querySelector('.nt-time');
if (timer) timer.addEventListener('animationend', function () { dismiss(toast); });
}
function dismiss(toast) {
if (!toast.isConnected || toast.classList.contains('is-leaving')) return;
var hadFocus = toast.contains(document.activeElement);
var sibling = toast.nextElementSibling || toast.previousElementSibling;
toast.classList.add('is-leaving');
function done() {
toast.remove();
if (!hadFocus) return;
var target = (sibling && sibling.matches('[data-toast]') && sibling.querySelector('button')) || lastTrigger;
if (target) target.focus();
}
if (reduce.matches || getComputedStyle(toast).animationName === 'none') return done();
toast.addEventListener('animationend', function (e) { if (e.target === toast) done(); });
}
function show(type, c) {
var tpl = document.querySelector('[data-toast-template="' + type + '"]');
if (!tpl) return;
var list = content[type];
c = c || list[n++ % list.length];
var toast = tpl.content.firstElementChild.cloneNode(true);
['avatar', 'title', 'text', 'meta'].forEach(function (k) {
var el = toast.querySelector('[data-' + k + ']');
if (el) el.textContent = c[k] || '';
});
stack.appendChild(toast);
arm(toast);
var live = stack.querySelectorAll('[data-toast]:not(.is-leaving)');
if (live.length > MAX) dismiss(live[0]);
}
stack.querySelectorAll('[data-toast]').forEach(arm);
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-toast-show]');
if (btn) { lastTrigger = btn; show(btn.dataset.toastShow); return; }
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-retry]')) { dismiss(toast); show('success', { title: 'Drive is back in sync', text: '3 files uploaded to Northwind / Projects.' }); }
});
// Esc dismisses the toast that has focus.
document.addEventListener('keydown', function (e) {
var toast = e.key === 'Escape' && e.target.closest && e.target.closest('[data-toast]');
if (toast) dismiss(toast);
});
var pos = document.querySelector('[data-toast-position]');
if (pos) pos.addEventListener('change', function () { stack.dataset.pos = pos.value; });
})();
/* Bootstrap .toast-container + .toast. Autohide is off: the ring around the close button is the timer.
Its CSS animation runs for --nt-time and the script hides the toast on animationend, so hover and focus pause it. */
.nt-stack,.nt-toast{--nt-brand:#4f46e5;--nt-ink:#0f172a;--nt-text:#475569;--nt-muted:#5b6474;--nt-line:#e2e8f0}
.nt-stack{z-index:1090;width:min(412px,100%);--bs-toast-spacing:0}
.nt-toast{--nt-accent:var(--nt-brand);--nt-soft:#eef2ff;--nt-from:-12px;
--bs-toast-spacing:0;--bs-toast-max-width:100%;--bs-toast-bg:#fff;--bs-toast-color:var(--nt-text);--bs-toast-border-color:var(--nt-line);--bs-toast-border-radius:16px;--bs-toast-font-size:.875rem;
--bs-toast-box-shadow:0 20px 40px -20px rgba(15,23,42,.38),0 2px 6px rgba(15,23,42,.06);line-height:1.45}
.nt-toast.show{animation:nt-in .3s cubic-bezier(.2,.8,.2,1)}
.nt-stack.flex-column .nt-toast{--nt-from:12px}
.nt-toast .toast-body{display:flex;align-items:flex-start;gap:12px;padding:14px 12px 14px 14px}
.nt-success{--nt-accent:#15803d;--nt-soft:#dcfce7}
.nt-warning{--nt-accent:#b45309;--nt-soft:#fef3c7}
.nt-error{--nt-accent:#dc2626;--nt-soft:#fee2e2;--bs-toast-border-color:#fecaca}
.nt-icon,.nt-avatar{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:50%;background:var(--nt-soft);color:var(--nt-accent)}
.nt-icon svg{width:19px;height:19px}
.nt-avatar{background:#ddd6fe;color:#4c1d95;font-size:.8125rem;font-weight:700;letter-spacing:.02em}
.nt-body{min-width:0}
.nt-title{margin:0;font-size:.9375rem;font-weight:600;color:var(--nt-ink)}
.nt-text{margin:2px 0 0}
.nt-meta{margin:6px 0 0;font-size:.75rem;color:var(--nt-muted)}
.nt-toast .btn-danger{--bs-btn-bg:#dc2626;--bs-btn-border-color:#dc2626;--bs-btn-hover-bg:#b91c1c;--bs-btn-hover-border-color:#b91c1c;--bs-btn-font-weight:600;--bs-btn-border-radius:9px;
--bs-btn-focus-box-shadow:0 0 0 2px #fff,0 0 0 4px #4f46e5;height:32px}
/* Close button with the timer ring around it */
.nt-close{position:relative;flex:none;display:grid;place-items:center;width:34px;height:34px;margin:-4px -2px 0 0}
.nt-close .btn-close{--bs-btn-close-opacity:.65;--bs-btn-close-hover-opacity:1;--bs-btn-close-focus-shadow:0 0 0 2px #4f46e5;width:34px;height:34px;padding:0;border-radius:50%;background-size:10px}
.nt-close .btn-close:hover{background-color:#f1f5f9}
.nt-ring{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg);pointer-events:none}
.nt-ring circle{fill:none;stroke-width:2.5}
.nt-ring .nt-track{stroke:#e2e8f0}
.nt-ring .nt-time{stroke:var(--nt-accent);stroke-dasharray:100;stroke-dashoffset:0;stroke-linecap:round;animation:nt-time var(--nt-time,6s) linear forwards}
.nt-stack:hover .nt-time,.nt-stack:focus-within .nt-time{animation-play-state:paused}
@keyframes nt-in{from{transform:translateY(var(--nt-from)) scale(.97)}}
@keyframes nt-time{to{stroke-dashoffset:100}}
.nt-static{--bs-toast-box-shadow:0 1px 2px rgba(15,23,42,.05),0 12px 28px -20px rgba(15,23,42,.35)}
.nt-static.show{animation:none}
@media (prefers-reduced-motion:reduce){.nt-toast.show{animation:none}.nt-toast.fade{transition:none}}
JavaScript
(function () {
var stack = document.querySelector('[data-toast-stack]');
if (!stack) return;
var MAX = 4, lastTrigger = null, n = 0;
var POS = { tr: 'top-0 end-0 flex-column-reverse', tc: 'top-0 start-50 translate-middle-x flex-column-reverse', br: 'bottom-0 end-0 flex-column', bl: 'bottom-0 start-0 flex-column' };
var content = {
mention: [
{ avatar: 'LO', title: 'Leo Okafor replied to you', text: '“Pushed the new hero copy, take a look when you can.”', meta: 'Website launch · just now' },
{ avatar: 'MK', title: 'Mina Kovač mentioned you', text: '“@Sam the client approved option B.”', meta: 'Bramble rebrand · just now' }
],
success: [{ title: 'Invoice INV-2042 sent', text: 'Harbor Labs will get it at [email protected].' }],
error: [{ title: 'Google Drive sync failed', text: 'We couldn’t reach Drive. Your files are saved locally.' }]
};
function arm(toast) {
var timer = toast.querySelector('.nt-time');
if (timer) timer.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 show(type, c) {
var tpl = document.querySelector('[data-toast-template="' + type + '"]');
if (!tpl) return;
var list = content[type];
c = c || list[n++ % list.length];
var toast = tpl.content.firstElementChild.cloneNode(true);
['avatar', 'title', 'text', 'meta'].forEach(function (k) {
var el = toast.querySelector('[data-' + k + ']');
if (el) el.textContent = c[k] || '';
});
stack.appendChild(toast);
arm(toast);
bootstrap.Toast.getOrCreateInstance(toast).show();
var live = stack.querySelectorAll('[data-toast]:not([data-leaving])');
if (live.length > MAX) dismiss(live[0]);
}
stack.querySelectorAll('[data-toast]').forEach(arm);
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-toast-show]');
if (btn) { lastTrigger = btn; show(btn.dataset.toastShow); return; }
var toast = e.target.closest('[data-toast]');
if (toast && e.target.closest('[data-toast-retry]')) { dismiss(toast); show('success', { title: 'Drive is back in sync', text: '3 files uploaded to Northwind / Projects.' }); }
});
// 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);
});
var pos = document.querySelector('[data-toast-position]');
if (pos) pos.addEventListener('change', function () {
Object.keys(POS).forEach(function (k) { stack.classList.remove.apply(stack.classList, POS[k].split(' ')); });
stack.classList.add.apply(stack.classList, POS[pos.value].split(' '));
});
})();
/* Entry and exit, and the ring timer: the script dismisses a toast on the timer's animationend,
so pausing it on hover or focus pauses the countdown. */
@theme {
--animate-toast-in: toast-in .3s cubic-bezier(.2,.8,.2,1);
--animate-toast-out: toast-out .2s ease-in forwards;
--animate-toast-time: toast-time var(--nt-time, 6s) linear forwards;
@keyframes toast-in { from { opacity: 0; transform: translateY(var(--nt-from, -12px)) scale(.97); } }
@keyframes toast-out { to { opacity: 0; transform: scale(.96); } }
@keyframes toast-time { from { stroke-dashoffset: 0; } to { stroke-dashoffset: 100; } }
}
JavaScript
(function () {
var stack = document.querySelector('[data-toast-stack]');
if (!stack) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var MAX = 4, lastTrigger = null, n = 0;
var content = {
mention: [
{ avatar: 'LO', title: 'Leo Okafor replied to you', text: '“Pushed the new hero copy, take a look when you can.”', meta: 'Website launch · just now' },
{ avatar: 'MK', title: 'Mina Kovač mentioned you', text: '“@Sam the client approved option B.”', meta: 'Bramble rebrand · just now' }
],
success: [{ title: 'Invoice INV-2042 sent', text: 'Harbor Labs will get it at [email protected].' }],
error: [{ title: 'Google Drive sync failed', text: 'We couldn’t reach Drive. Your files are saved locally.' }]
};
function arm(toast) {
var timer = toast.querySelector('[data-timer]');
if (timer) timer.addEventListener('animationend', function () { dismiss(toast); });
}
function dismiss(toast) {
if (!toast.isConnected || toast.hasAttribute('data-leaving')) return;
var hadFocus = toast.contains(document.activeElement);
var sibling = toast.nextElementSibling || toast.previousElementSibling;
toast.setAttribute('data-leaving', '');
function done() {
toast.remove();
if (!hadFocus) return;
var target = (sibling && sibling.matches('[data-toast]') && sibling.querySelector('button')) || lastTrigger;
if (target) target.focus();
}
if (reduce.matches || getComputedStyle(toast).animationName === 'none') return done();
toast.addEventListener('animationend', function (e) { if (e.target === toast) done(); });
}
function show(type, c) {
var tpl = document.querySelector('[data-toast-template="' + type + '"]');
if (!tpl) return;
var list = content[type];
c = c || list[n++ % list.length];
var toast = tpl.content.firstElementChild.cloneNode(true);
['avatar', 'title', 'text', 'meta'].forEach(function (k) {
var el = toast.querySelector('[data-' + k + ']');
if (el) el.textContent = c[k] || '';
});
stack.appendChild(toast);
arm(toast);
var live = stack.querySelectorAll('[data-toast]:not([data-leaving])');
if (live.length > MAX) dismiss(live[0]);
}
stack.querySelectorAll('[data-toast]').forEach(arm);
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-toast-show]');
if (btn) { lastTrigger = btn; show(btn.dataset.toastShow); return; }
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-retry]')) { dismiss(toast); show('success', { title: 'Drive is back in sync', text: '3 files uploaded to Northwind / Projects.' }); }
});
// Esc dismisses the toast that has focus.
document.addEventListener('keydown', function (e) {
var toast = e.key === 'Escape' && e.target.closest && e.target.closest('[data-toast]');
if (toast) dismiss(toast);
});
var pos = document.querySelector('[data-toast-position]');
if (pos) pos.addEventListener('change', function () { stack.dataset.pos = pos.value; });
})();
About this set
A live toast stack inside a small app window, plus four static toasts for info, success, warning and error. Three buttons raise a mention toast with the sender’s initials and the message, a success toast for a sent invoice and an error toast for a failed Google Drive sync with a Retry sync button. A position menu moves the stack between top right, top centre, bottom right and bottom left, and new toasts enter from the nearest edge. The timer is visible: a ring around each close button empties over the toast’s lifetime, which is set per toast with one custom property, and the script dismisses the toast when that CSS animation ends. Because the animation is the timer, pointing at the stack or tabbing into it pauses every countdown, so nobody loses a message they are reading. Error toasts have no ring and stay until closed. The stack keeps at most four toasts and drops the oldest, Esc closes the toast that has focus, and focus then moves to the next toast or back to the button that raised it. Use the static variants for in-page messages that should not move. The Bootstrap version is built on .toast-container, .toast and the Toast plugin with autohide turned off.
What’s included
Visible ring timer around the close button, set per toast with --nt-time
Hover or keyboard focus pauses every countdown in the stack
Four positions: top right, top centre, bottom right, bottom left
Errors use role="alert" and never auto-dismiss
At most four toasts; Esc closes the focused one
Bootstrap .toast-container and .toast with position utilities
Accessibility
The stack is a polite live region (role="status", aria-live="polite") named Notifications, and error toasts use role="alert" so they are announced at once. Every close button is named Dismiss notification and shows a 2px focus outline, and closing a toast moves focus to the next toast or back to its trigger. Hover and focus pause the timers. Under prefers-reduced-motion the slide in and out is switched off; the ring keeps emptying because it is the countdown.
Customise it
In plain CSS change --nt-brand, --nt-ink and --nt-text on .nt-stack, the --nt-accent and --nt-soft pair on .nt-success, .nt-warning and .nt-error, and --nt-time on each toast. In Bootstrap the same classes also set the --bs-toast-* variables; in Tailwind edit the hex values in the border-, bg-, text- and stroke- utilities and the --nt-time inline style.