.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.tv-close{flex:none;display:grid;place-items:center;width:28px;height:28px;padding:0;border:0;border-radius:7px;background:transparent;color:#64748b;cursor:pointer;transition:background-color .15s}
.tv-close:hover{background:#f1f5f9;color:#0f172a}
.tv-close svg{width:15px;height:15px}
.tv-close:focus-visible{outline:2px solid #4f46e5;outline-offset:2px}
/* 1. Position picker: six radios move the region with :has(), no script */
.tv-pos{display:grid;gap:14px}
.tv-pos fieldset{min-width:0;margin:0;padding:0;border:0}
.tv-pos legend{margin:0 0 8px;padding:0;font-size:.875rem;font-weight:650;color:#0f172a}
.tv-picker{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:4px;max-width:440px;padding:5px;border-radius:12px;background:#e2e8f0}
.tv-opt{position:relative;display:block}
.tv-opt input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
.tv-opt span{display:block;padding:7px 6px;border-radius:8px;color:#475569;font-size:.8125rem;font-weight:600;text-align:center;transition:background-color .15s,color .15s}
.tv-opt input:checked+span{background:#fff;color:#3730a3;box-shadow:0 1px 3px rgba(15,23,42,.16)}
.tv-opt input:focus-visible+span{outline:2px solid #4f46e5;outline-offset:1px}
.tv-screen{position:relative;height:280px;overflow:hidden;background:#f8fafc;border:1px solid #d6dce5;border-radius:16px}
.tv-region{position:absolute;right:16px;bottom:16px;width:min(300px,calc(100% - 32px))}
.tv-pos:has([value="top-left"]:checked) .tv-region{inset:16px auto auto 16px}
.tv-pos:has([value="top-center"]:checked) .tv-region{inset:16px auto auto 50%;translate:-50% 0}
.tv-pos:has([value="top-right"]:checked) .tv-region{inset:16px 16px auto auto}
.tv-pos:has([value="bottom-left"]:checked) .tv-region{inset:auto auto 16px 16px}
.tv-pos:has([value="bottom-center"]:checked) .tv-region{inset:auto auto 16px 50%;translate:-50% 0}
/* 2. Compact snackbars */
.tv-snack{display:inline-flex;align-items:center;gap:10px;max-width:100%;padding:8px 8px 8px 16px;border-radius:999px;background:#1e1b4b;color:#e0e7ff;
font-size:.875rem;box-shadow:0 14px 30px -14px rgba(30,27,75,.7)}
.tv-snack>svg{flex:none;width:18px;height:18px;color:#86efac}
.tv-snack p{margin:0;min-width:0}
.tv-snack .tv-link{height:30px;padding:0 12px;border:0;border-radius:99px;background:rgba(165,180,252,.16);color:#c7d2fe;font:inherit;font-size:.8125rem;font-weight:700;cursor:pointer;transition:background-color .15s}
.tv-snack .tv-link:hover{background:rgba(165,180,252,.28)}
.tv-snack .tv-close{border-radius:99px;color:#c7d2fe}
.tv-snack .tv-close:hover{background:rgba(255,255,255,.1);color:#fff}
.tv-snack .tv-link:focus-visible,.tv-snack .tv-close:focus-visible{outline-color:#a5b4fc}
.tv-region .tv-snack{display:flex;border-radius:14px}
/* 3. Message toast with avatar */
.tv-msg{display:flex;gap:12px;width:min(360px,100%);padding:14px 12px 14px 14px;background:#fff;border:1px solid #e2e8f0;border-radius:16px;
box-shadow:0 18px 36px -18px rgba(15,23,42,.35);color:#475569;font-size:.875rem;line-height:1.45}
.tv-avatar{position:relative;flex:none;display:grid;place-items:center;width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#f472b6,#8b5cf6);color:#fff;font-size:.8125rem;font-weight:800}
.tv-avatar.is-teal{background:linear-gradient(135deg,#2dd4bf,#0284c7)}
.tv-avatar i{position:absolute;right:-1px;bottom:-1px;width:12px;height:12px;border:2px solid #fff;border-radius:50%;background:#22c55e}
.tv-msg-body{flex:1;min-width:0}
.tv-msg-head{display:flex;align-items:baseline;gap:8px;margin:0}
.tv-msg-head b{color:#0f172a;font-size:.9375rem}
.tv-msg-head time{font-size:.75rem;color:#64748b}
.tv-msg-text{margin:2px 0 0;color:#334155}
.tv-msg-actions{display:flex;gap:8px;margin-top:10px}
.tv-btn{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}
.tv-btn:hover{background:#f1f5f9}
.tv-btn-primary{border-color:#4f46e5;background:#4f46e5;color:#fff}
.tv-btn-primary:hover{background:#4338ca}
.tv-btn:focus-visible{outline:2px solid #4f46e5;outline-offset:2px}
/* 4. Promise toast: one toast that goes from loading to success. The script swaps the text and data-state. */
.tv-promise-stack{display:flex;flex-direction:column;gap:10px;width:min(360px,100%)}
.tv-promise{display:flex;align-items:center;gap:12px;padding:12px 10px 12px 14px;background:#fff;border:1px solid #e2e8f0;border-radius:14px;box-shadow:0 18px 36px -18px rgba(15,23,42,.35);
color:#475569;font-size:.875rem;line-height:1.45}
.tv-state{position:relative;flex:none;width:28px;height:28px}
.tv-spinner{position:absolute;inset:3px;border:2.5px solid #e0e7ff;border-top-color:#4f46e5;border-radius:50%;animation:tv-spin .8s linear infinite}
.tv-done{position:absolute;inset:0;display:none;place-items:center;border-radius:50%;background:#dcfce7;color:#16a34a}
.tv-done svg{width:16px;height:16px}
.tv-promise[data-state="done"] .tv-spinner{display:none}
.tv-promise[data-state="done"] .tv-done{display:grid;animation:tv-pop .3s cubic-bezier(.2,.9,.3,1.4)}
.tv-promise-body{flex:1;min-width:0}
.tv-promise-title{margin:0;font-weight:650;color:#0f172a}
.tv-promise-text{margin:1px 0 0}
.tv-trigger{align-self:flex-start}
@keyframes tv-spin{to{transform:rotate(360deg)}}
@keyframes tv-pop{from{transform:scale(.4);opacity:0}}
@media (prefers-reduced-motion:reduce){
.tv-spinner{animation-duration:2.4s}
.tv-promise[data-state="done"] .tv-done{animation:none}
.tv-opt span,.tv-close,.tv-btn,.tv-snack .tv-link{transition:none}
}
JavaScript
// Dismiss: [data-close] removes its [data-toast] and moves focus to the next control so keyboard users keep their place.
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-close]');
var toast = btn && btn.closest('[data-toast]');
if (!toast) return;
var sibling = toast.nextElementSibling || toast.previousElementSibling;
toast.remove();
var next = sibling && (sibling.matches('button') ? sibling : sibling.querySelector('button'));
if (next) next.focus();
});
// Promise toast: show a loading toast, then turn the same toast into a success message when the work
// finishes. The stack is a polite live region, so the new text is announced once.
(function () {
var stack = document.querySelector('[data-promise-stack]');
var tpl = document.querySelector('[data-promise-template]');
if (!stack || !tpl) return;
var n = 26;
function resolve(toast, title, text) {
// Replace this timeout with your own promise: upload(file).then(function () { ... }).
setTimeout(function () {
toast.dataset.state = 'done';
toast.querySelector('[data-title]').textContent = title;
toast.querySelector('[data-text]').textContent = text;
}, 2600);
}
stack.querySelectorAll('[data-run]').forEach(function (toast) {
resolve(toast, 'Invoice uploaded', 'invoice-0925.pdf was added to October expenses.');
});
stack.addEventListener('click', function (e) {
var trigger = e.target.closest('[data-upload]');
if (!trigger) return;
var file = 'invoice-09' + (n++) + '.pdf';
var toast = tpl.content.firstElementChild.cloneNode(true);
toast.querySelector('[data-title]').textContent = 'Uploading ' + file;
toast.querySelector('[data-text]').textContent = '1.8 MB, a few seconds left';
stack.insertBefore(toast, trigger);
var all = stack.querySelectorAll('[data-promise]');
if (all.length > 3) all[0].remove();
resolve(toast, 'Invoice uploaded', file + ' was added to October expenses.');
});
})();
/* Every toast here is a Bootstrap .toast (.show when static); dismiss buttons use data-bs-dismiss="toast"; the spinner is .spinner-border. */
.tv{--bs-toast-max-width:100%;--bs-toast-bg:#fff;--bs-toast-border-color:#e2e8f0;--bs-toast-border-radius:14px;--bs-toast-font-size:.875rem;--bs-toast-color:#475569;
--bs-toast-box-shadow:0 18px 36px -18px rgba(15,23,42,.35);line-height:1.45}
.tv .toast-body{display:flex;align-items:center;gap:12px}
.tv .btn-close{--bs-btn-close-opacity:.6;--bs-btn-close-hover-opacity:1;--bs-btn-close-focus-shadow:0 0 0 2px #4f46e5;flex:none;width:28px;height:28px;padding:0;border-radius:7px;background-size:10px}
.tv .btn-close:hover{background-color:#f1f5f9}
/* 1. Position picker: .btn-check radios move the region with :has(), no script */
.tv-pos{display:grid;gap:14px}
.tv-pos fieldset{min-width:0}
.tv-pos legend{float:none;width:auto;margin:0 0 8px;font-size:.875rem;font-weight:650;color:#0f172a}
.tv-picker{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:4px;max-width:440px;padding:5px;border-radius:12px;background:#e2e8f0}
.tv-picker .btn{--bs-btn-padding-y:7px;--bs-btn-padding-x:6px;--bs-btn-font-size:.8125rem;--bs-btn-font-weight:600;--bs-btn-border-radius:8px;--bs-btn-border-width:0;--bs-btn-color:#475569;
--bs-btn-hover-color:#0f172a;--bs-btn-active-bg:#fff;--bs-btn-active-color:#3730a3;--bs-btn-focus-box-shadow:0 0 0 2px #4f46e5;line-height:1.5}
.tv-picker .btn-check:checked+.btn{background:#fff;color:#3730a3;box-shadow:0 1px 3px rgba(15,23,42,.16)}
.tv-picker .btn-check:checked:focus-visible+.btn{box-shadow:0 0 0 2px #4f46e5}
.tv-screen{position:relative;height:280px;overflow:hidden;background:#f8fafc;border:1px solid #d6dce5;border-radius:16px}
.tv-region{position:absolute;right:16px;bottom:16px;width:min(300px,calc(100% - 32px))}
.tv-pos:has([value="top-left"]:checked) .tv-region{inset:16px auto auto 16px}
.tv-pos:has([value="top-center"]:checked) .tv-region{inset:16px auto auto 50%;translate:-50% 0}
.tv-pos:has([value="top-right"]:checked) .tv-region{inset:16px 16px auto auto}
.tv-pos:has([value="bottom-left"]:checked) .tv-region{inset:auto auto 16px 16px}
.tv-pos:has([value="bottom-center"]:checked) .tv-region{inset:auto auto 16px 50%;translate:-50% 0}
/* 2. Compact snackbars */
.tv-snack{--bs-toast-bg:#1e1b4b;--bs-toast-color:#e0e7ff;--bs-toast-border-color:#1e1b4b;--bs-toast-border-radius:999px;--bs-toast-box-shadow:0 14px 30px -14px rgba(30,27,75,.7);width:auto;max-width:100%}
.tv-snack .toast-body{gap:10px;padding:8px 8px 8px 16px}
.tv-snack .toast-body>svg{flex:none;width:18px;height:18px;color:#86efac}
.tv-snack p{margin:0;min-width:0}
.tv-snack .btn{--bs-btn-padding-x:12px;--bs-btn-font-size:.8125rem;--bs-btn-font-weight:700;--bs-btn-border-radius:99px;--bs-btn-border-width:0;--bs-btn-bg:rgba(165,180,252,.16);--bs-btn-color:#c7d2fe;
--bs-btn-hover-bg:rgba(165,180,252,.28);--bs-btn-hover-color:#c7d2fe;--bs-btn-active-bg:rgba(165,180,252,.34);--bs-btn-active-color:#c7d2fe;--bs-btn-focus-box-shadow:0 0 0 2px #a5b4fc;height:30px}
.tv-snack .btn-close{border-radius:99px;--bs-btn-close-opacity:.85;--bs-btn-close-focus-shadow:0 0 0 2px #a5b4fc}
.tv-snack .btn-close:hover{background-color:rgba(255,255,255,.1)}
.tv-region .tv-snack{width:100%;--bs-toast-border-radius:14px}
/* 3. Message toast with avatar */
.tv-msg{--bs-toast-border-radius:16px;width:min(360px,100%)}
.tv-msg .toast-body{align-items:flex-start;padding:14px 12px 14px 14px}
.tv-avatar{position:relative;flex:none;display:grid;place-items:center;width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#f472b6,#8b5cf6);color:#fff;font-size:.8125rem;font-weight:800}
.tv-avatar.is-teal{background:linear-gradient(135deg,#2dd4bf,#0284c7)}
.tv-avatar i{position:absolute;right:-1px;bottom:-1px;width:12px;height:12px;border:2px solid #fff;border-radius:50%;background:#22c55e}
.tv-msg-body{flex:1;min-width:0}
.tv-msg-head{display:flex;align-items:baseline;gap:8px;margin:0}
.tv-msg-head b{color:#0f172a;font-size:.9375rem}
.tv-msg-head time{font-size:.75rem;color:#64748b}
.tv-msg-text{margin:2px 0 0;color:#334155}
.tv-msg-actions{display:flex;gap:8px;margin-top:10px}
.tv-btn{--bs-btn-padding-x:12px;--bs-btn-font-size:.8125rem;--bs-btn-font-weight:650;--bs-btn-border-radius:8px;--bs-btn-focus-box-shadow:0 0 0 2px #fff,0 0 0 4px #4f46e5;height:32px}
.tv-btn.btn-primary{--bs-btn-bg:#4f46e5;--bs-btn-border-color:#4f46e5;--bs-btn-hover-bg:#4338ca;--bs-btn-hover-border-color:#4338ca;--bs-btn-active-bg:#3730a3;--bs-btn-active-border-color:#3730a3}
.tv-btn.btn-light{--bs-btn-bg:#fff;--bs-btn-border-color:#cbd5e1;--bs-btn-color:#0f172a;--bs-btn-hover-bg:#f1f5f9;--bs-btn-hover-border-color:#cbd5e1;--bs-btn-active-bg:#e2e8f0}
/* 4. Promise toast: .spinner-border while loading, a check when data-state="done" */
.tv-promise-stack{display:flex;flex-direction:column;gap:10px;width:min(360px,100%)}
.tv-promise .toast-body{padding:12px 10px 12px 14px}
.tv-state{position:relative;flex:none;width:28px;height:28px}
.tv-state .spinner-border{--bs-spinner-width:22px;--bs-spinner-height:22px;--bs-spinner-border-width:2.5px;--bs-spinner-animation-speed:.8s;position:absolute;inset:3px;color:#4f46e5;border-right-color:#e0e7ff;border-bottom-color:#e0e7ff;border-left-color:#e0e7ff}
.tv-done{position:absolute;inset:0;display:none;place-items:center;border-radius:50%;background:#dcfce7;color:#16a34a}
.tv-done svg{width:16px;height:16px}
.tv-promise[data-state="done"] .spinner-border{display:none}
.tv-promise[data-state="done"] .tv-done{display:grid;animation:tv-pop .3s cubic-bezier(.2,.9,.3,1.4)}
.tv-promise-title{margin:0;font-weight:650;color:#0f172a}
.tv-promise-text{margin:1px 0 0}
.tv-trigger{align-self:flex-start}
@keyframes tv-pop{from{transform:scale(.4);opacity:0}}
@media (prefers-reduced-motion:reduce){
.tv-state .spinner-border{--bs-spinner-animation-speed:2.4s}
.tv-promise[data-state="done"] .tv-done{animation:none}
.tv.fade{transition:none}
}
JavaScript
// Bootstrap hides a dismissed toast; remove it and move focus to the next control so keyboard users keep their place.
document.addEventListener('hide.bs.toast', function (e) {
var toast = e.target;
var sibling = toast.nextElementSibling || toast.previousElementSibling;
toast._next = toast.contains(document.activeElement) && sibling && (sibling.matches('button') ? sibling : sibling.querySelector('button'));
});
document.addEventListener('hidden.bs.toast', function (e) {
var next = e.target._next;
e.target.remove();
if (next) next.focus();
});
// Promise toast: show a loading toast, then turn the same toast into a success message when the work
// finishes. The stack is a polite live region, so the new text is announced once.
(function () {
var stack = document.querySelector('[data-promise-stack]');
var tpl = document.querySelector('[data-promise-template]');
if (!stack || !tpl) return;
var n = 26;
function resolve(toast, title, text) {
// Replace this timeout with your own promise: upload(file).then(function () { ... }).
setTimeout(function () {
toast.dataset.state = 'done';
toast.querySelector('[data-title]').textContent = title;
toast.querySelector('[data-text]').textContent = text;
}, 2600);
}
stack.querySelectorAll('[data-run]').forEach(function (toast) {
resolve(toast, 'Invoice uploaded', 'invoice-0925.pdf was added to October expenses.');
});
stack.addEventListener('click', function (e) {
var trigger = e.target.closest('[data-upload]');
if (!trigger) return;
var file = 'invoice-09' + (n++) + '.pdf';
var toast = tpl.content.firstElementChild.cloneNode(true);
toast.querySelector('[data-title]').textContent = 'Uploading ' + file;
toast.querySelector('[data-text]').textContent = '1.8 MB, a few seconds left';
stack.insertBefore(toast, trigger);
bootstrap.Toast.getOrCreateInstance(toast).show();
var all = stack.querySelectorAll('[data-promise]');
if (all.length > 3) all[0].remove();
resolve(toast, 'Invoice uploaded', file + ' was added to October expenses.');
});
})();
@theme {
--animate-pop: pop .3s cubic-bezier(.2,.9,.3,1.4);
@keyframes pop { from { transform: scale(.4); opacity: 0; } }
}
JavaScript
// Dismiss: [data-close] removes its [data-toast] and moves focus to the next control so keyboard users keep their place.
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-close]');
var toast = btn && btn.closest('[data-toast]');
if (!toast) return;
var sibling = toast.nextElementSibling || toast.previousElementSibling;
toast.remove();
var next = sibling && (sibling.matches('button') ? sibling : sibling.querySelector('button'));
if (next) next.focus();
});
// Promise toast: show a loading toast, then turn the same toast into a success message when the work
// finishes. The stack is a polite live region, so the new text is announced once.
(function () {
var stack = document.querySelector('[data-promise-stack]');
var tpl = document.querySelector('[data-promise-template]');
if (!stack || !tpl) return;
var n = 26;
function resolve(toast, title, text) {
// Replace this timeout with your own promise: upload(file).then(function () { ... }).
setTimeout(function () {
toast.dataset.state = 'done';
toast.querySelector('[data-title]').textContent = title;
toast.querySelector('[data-text]').textContent = text;
}, 2600);
}
stack.querySelectorAll('[data-run]').forEach(function (toast) {
resolve(toast, 'Invoice uploaded', 'invoice-0925.pdf was added to October expenses.');
});
stack.addEventListener('click', function (e) {
var trigger = e.target.closest('[data-upload]');
if (!trigger) return;
var file = 'invoice-09' + (n++) + '.pdf';
var toast = tpl.content.firstElementChild.cloneNode(true);
toast.querySelector('[data-title]').textContent = 'Uploading ' + file;
toast.querySelector('[data-text]').textContent = '1.8 MB, a few seconds left';
stack.insertBefore(toast, trigger);
var all = stack.querySelectorAll('[data-promise]');
if (all.length > 3) all[0].remove();
resolve(toast, 'Invoice uploaded', file + ' was added to October expenses.');
});
})();
About this set
Four more toast patterns. The position picker is a segmented control of six radio buttons, top left to bottom right, that moves a sample toast around a preview screen with :has() and no script, so you can see which corner suits a layout before you commit. Compact snackbars are dark pills for quick confirmations such as Draft saved, or for a reversible action such as 3 photos moved to Trash with an Undo button. Message toasts carry an avatar with an online dot, the sender’s name, a relative time and the message, with Reply and Mark as read buttons for chat and comment notifications. The promise toast is the loading-to-success pattern: a toast appears with a spinner and Uploading invoice-0925.pdf, and when the work finishes the same toast turns into Invoice uploaded with a green tick. A short script swaps the text and a data-state attribute, and the stack is a polite live region so the result is announced once. Upload another invoice runs it again from a template. The Bootstrap version uses .toast, .btn-check radios and .spinner-border, and removes toasts after the Toast plugin hides them.
What’s included
Six positions chosen with radio buttons and :has()
Compact snackbar with an Undo action
Message toast with avatar, time and reply buttons
Loading to success promise toast with a spinner
Bootstrap .btn-check, .toast and .spinner-border
Accessibility
The position picker is a fieldset with a legend and real radio buttons, so arrow keys move between positions and the focus ring is drawn on the visible pill. Toasts use role="status" and the promise stack is a polite live region, so the success text is announced once. Every close button is named, including who a message is from, focus moves on after a dismissal, and the spinner slows and the pop animation stops under prefers-reduced-motion.
Customise it
The picker offsets are the inset values in the .tv-pos:has(...) rules. Snackbar colours are on .tv-snack, avatar gradients on .tv-avatar, and the spinner colours on .tv-spinner. Replace the setTimeout in resolve() with your own promise. In Bootstrap the same colours are --bs-toast-* and --bs-btn-* variables; in Tailwind edit the utilities.