Open demo

CSS code

HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <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>
  <symbol id="i-ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 12.5 4 4 8-9"/></symbol>
  <symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></symbol>
  <symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13"/></symbol>
</svg>

<div><span class="lbl">Six positions, chosen with :has()</span>
<div class="tv-pos">
  <fieldset>
    <legend>Toast position</legend>
    <div class="tv-picker">
      <label class="tv-opt"><input type="radio" name="tv-pos" value="top-left"><span>Top left</span></label>
      <label class="tv-opt"><input type="radio" name="tv-pos" value="top-center"><span>Top centre</span></label>
      <label class="tv-opt"><input type="radio" name="tv-pos" value="top-right"><span>Top right</span></label>
      <label class="tv-opt"><input type="radio" name="tv-pos" value="bottom-left"><span>Bottom left</span></label>
      <label class="tv-opt"><input type="radio" name="tv-pos" value="bottom-center"><span>Bottom centre</span></label>
      <label class="tv-opt"><input type="radio" name="tv-pos" value="bottom-right" checked><span>Bottom right</span></label>
    </div>
  </fieldset>
  <div class="tv-screen">
    <div class="skel" aria-hidden="true"><i style="width:38%;height:16px"></i><i style="width:72%"></i><i style="width:64%"></i><i style="width:68%"></i></div>
    <div class="tv-region" role="status">
      <div class="tv-snack"><svg aria-hidden="true"><use href="#i-link"/></svg><p>Link copied to clipboard</p></div>
    </div>
  </div>
</div></div>

<div><span class="lbl">Compact snackbars</span>
<div class="surface">
  <div class="tv-snack" role="status" data-toast><svg aria-hidden="true"><use href="#i-ok"/></svg><p>Draft saved</p><button class="tv-close" type="button" aria-label="Dismiss" data-close><svg aria-hidden="true"><use href="#i-x"/></svg></button></div>
  <div class="tv-snack" role="status" data-toast><svg aria-hidden="true" style="color:#fca5a5"><use href="#i-trash"/></svg><p>3 photos moved to Trash</p><button class="tv-link" type="button" data-close>Undo</button><button class="tv-close" type="button" aria-label="Dismiss" data-close><svg aria-hidden="true"><use href="#i-x"/></svg></button></div>
</div></div>

<div><span class="lbl">Message toasts with avatar</span>
<div class="surface">
  <div class="tv-msg" role="status" data-toast>
    <span class="tv-avatar" aria-hidden="true">MO<i></i></span>
    <div class="tv-msg-body">
      <p class="tv-msg-head"><b>Maya Okafor</b><time datetime="2026-10-14T15:12">now</time></p>
      <p class="tv-msg-text">Can you check the Q3 deck before the 4pm call? Slide 12 still has last year's numbers.</p>
      <div class="tv-msg-actions"><button class="tv-btn tv-btn-primary" type="button">Reply</button><button class="tv-btn" type="button" data-close>Mark as read</button></div>
    </div>
    <button class="tv-close" type="button" aria-label="Dismiss message from Maya Okafor" data-close><svg aria-hidden="true"><use href="#i-x"/></svg></button>
  </div>
  <div class="tv-msg" role="status" data-toast>
    <span class="tv-avatar is-teal" aria-hidden="true">LB</span>
    <div class="tv-msg-body">
      <p class="tv-msg-head"><b>Leo Brandt</b><time datetime="2026-10-14T15:08">4 min ago</time></p>
      <p class="tv-msg-text">Commented on <strong>Homepage v3</strong>: “Hero copy reads well. Ship it.”</p>
    </div>
    <button class="tv-close" type="button" aria-label="Dismiss comment from Leo Brandt" data-close><svg aria-hidden="true"><use href="#i-x"/></svg></button>
  </div>
</div></div>

<div><span class="lbl">Promise toast: loading, then success</span>
<div class="surface">
  <div class="tv-promise-stack" aria-live="polite" data-promise-stack>
    <div class="tv-promise" data-state="done" data-toast data-promise>
      <span class="tv-state" aria-hidden="true"><span class="tv-spinner"></span><span class="tv-done"><svg><use href="#i-ok"/></svg></span></span>
      <div class="tv-promise-body"><p class="tv-promise-title" data-title>Invoice uploaded</p><p class="tv-promise-text" data-text>invoice-0918.pdf was added to September expenses.</p></div>
      <button class="tv-close" type="button" aria-label="Dismiss" data-close><svg aria-hidden="true"><use href="#i-x"/></svg></button>
    </div>
    <div class="tv-promise" data-state="loading" data-toast data-promise data-run>
      <span class="tv-state" aria-hidden="true"><span class="tv-spinner"></span><span class="tv-done"><svg><use href="#i-ok"/></svg></span></span>
      <div class="tv-promise-body"><p class="tv-promise-title" data-title>Uploading invoice-0925.pdf</p><p class="tv-promise-text" data-text>2.4 MB, a few seconds left</p></div>
      <button class="tv-close" type="button" aria-label="Dismiss" data-close><svg aria-hidden="true"><use href="#i-x"/></svg></button>
    </div>
    <button class="tv-btn tv-btn-primary tv-trigger" type="button" data-upload>Upload another invoice</button>
  </div>
  <template data-promise-template><div class="tv-promise" data-state="loading" data-toast data-promise><span class="tv-state" aria-hidden="true"><span class="tv-spinner"></span><span class="tv-done"><svg><use href="#i-ok"/></svg></span></span><div class="tv-promise-body"><p class="tv-promise-title" data-title></p><p class="tv-promise-text" data-text></p></div><button class="tv-close" type="button" aria-label="Dismiss" data-close><svg aria-hidden="true"><use href="#i-x"/></svg></button></div></template>
</div></div>
CSS
.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.');
  });
})();

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.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 12.5 4 4 8-9"/></symbol>
  <symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></symbol>
  <symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13"/></symbol>
</svg>

<div><span class="lbl">Six positions, chosen with :has()</span>
<div class="tv-pos">
  <fieldset>
    <legend>Toast position</legend>
    <div class="tv-picker">
      <input class="btn-check" type="radio" name="tv-pos" id="tv-pos-tl" value="top-left"><label class="btn" for="tv-pos-tl">Top left</label>
      <input class="btn-check" type="radio" name="tv-pos" id="tv-pos-tc" value="top-center"><label class="btn" for="tv-pos-tc">Top centre</label>
      <input class="btn-check" type="radio" name="tv-pos" id="tv-pos-tr" value="top-right"><label class="btn" for="tv-pos-tr">Top right</label>
      <input class="btn-check" type="radio" name="tv-pos" id="tv-pos-bl" value="bottom-left"><label class="btn" for="tv-pos-bl">Bottom left</label>
      <input class="btn-check" type="radio" name="tv-pos" id="tv-pos-bc" value="bottom-center"><label class="btn" for="tv-pos-bc">Bottom centre</label>
      <input class="btn-check" type="radio" name="tv-pos" id="tv-pos-br" value="bottom-right" checked><label class="btn" for="tv-pos-br">Bottom right</label>
    </div>
  </fieldset>
  <div class="tv-screen">
    <div class="skel" aria-hidden="true"><i style="width:38%;height:16px"></i><i style="width:72%"></i><i style="width:64%"></i><i style="width:68%"></i></div>
    <div class="toast-container tv-region" role="status">
      <div class="toast tv tv-snack show"><div class="toast-body"><svg aria-hidden="true"><use href="#i-link"/></svg><p>Link copied to clipboard</p></div></div>
    </div>
  </div>
</div></div>

<div><span class="lbl">Compact snackbars</span>
<div class="surface">
  <div class="toast tv tv-snack fade show" role="status" data-bs-autohide="false"><div class="toast-body"><svg aria-hidden="true"><use href="#i-ok"/></svg><p>Draft saved</p><button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Dismiss"></button></div></div>
  <div class="toast tv tv-snack fade show" role="status" data-bs-autohide="false"><div class="toast-body"><svg aria-hidden="true" style="color:#fca5a5"><use href="#i-trash"/></svg><p>3 photos moved to Trash</p><button class="btn" type="button" data-bs-dismiss="toast">Undo</button><button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Dismiss"></button></div></div>
</div></div>

<div><span class="lbl">Message toasts with avatar</span>
<div class="surface">
  <div class="toast tv tv-msg fade show" role="status" data-bs-autohide="false"><div class="toast-body">
    <span class="tv-avatar" aria-hidden="true">MO<i></i></span>
    <div class="tv-msg-body">
      <p class="tv-msg-head"><b>Maya Okafor</b><time datetime="2026-10-14T15:12">now</time></p>
      <p class="tv-msg-text">Can you check the Q3 deck before the 4pm call? Slide 12 still has last year's numbers.</p>
      <div class="tv-msg-actions"><button class="btn btn-primary tv-btn" type="button">Reply</button><button class="btn btn-light tv-btn" type="button" data-bs-dismiss="toast">Mark as read</button></div>
    </div>
    <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Dismiss message from Maya Okafor"></button>
  </div></div>
  <div class="toast tv tv-msg fade show" role="status" data-bs-autohide="false"><div class="toast-body">
    <span class="tv-avatar is-teal" aria-hidden="true">LB</span>
    <div class="tv-msg-body">
      <p class="tv-msg-head"><b>Leo Brandt</b><time datetime="2026-10-14T15:08">4 min ago</time></p>
      <p class="tv-msg-text">Commented on <strong>Homepage v3</strong>: “Hero copy reads well. Ship it.”</p>
    </div>
    <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Dismiss comment from Leo Brandt"></button>
  </div></div>
</div></div>

<div><span class="lbl">Promise toast: loading, then success</span>
<div class="surface">
  <div class="tv-promise-stack" aria-live="polite" data-promise-stack>
    <div class="toast tv tv-promise fade show" data-state="done" data-bs-autohide="false" data-promise><div class="toast-body">
      <span class="tv-state" aria-hidden="true"><span class="spinner-border"></span><span class="tv-done"><svg><use href="#i-ok"/></svg></span></span>
      <div class="flex-grow-1"><p class="tv-promise-title" data-title>Invoice uploaded</p><p class="tv-promise-text" data-text>invoice-0918.pdf was added to September expenses.</p></div>
      <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Dismiss"></button>
    </div></div>
    <div class="toast tv tv-promise fade show" data-state="loading" data-bs-autohide="false" data-promise data-run><div class="toast-body">
      <span class="tv-state" aria-hidden="true"><span class="spinner-border"></span><span class="tv-done"><svg><use href="#i-ok"/></svg></span></span>
      <div class="flex-grow-1"><p class="tv-promise-title" data-title>Uploading invoice-0925.pdf</p><p class="tv-promise-text" data-text>2.4 MB, a few seconds left</p></div>
      <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Dismiss"></button>
    </div></div>
    <button class="btn btn-primary tv-btn tv-trigger" type="button" data-upload>Upload another invoice</button>
  </div>
  <template data-promise-template><div class="toast tv tv-promise fade" data-state="loading" data-bs-autohide="false" data-promise><div class="toast-body"><span class="tv-state" aria-hidden="true"><span class="spinner-border"></span><span class="tv-done"><svg><use href="#i-ok"/></svg></span></span><div class="flex-grow-1"><p class="tv-promise-title" data-title></p><p class="tv-promise-text" data-text></p></div><button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Dismiss"></button></div></div></template>
</div></div>
CSS
/* 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.');
  });
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <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>
  <symbol id="i-ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 12.5 4 4 8-9"/></symbol>
  <symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></symbol>
  <symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13"/></symbol>
</svg>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#5b6474] uppercase">Six positions, chosen with :has()</span>
<div class="group/pos grid gap-3.5">
  <fieldset class="min-w-0">
    <legend class="mb-2 text-sm font-[650] text-[#0f172a]">Toast position</legend>
    <div class="grid max-w-[440px] grid-cols-3 gap-1 rounded-xl bg-[#e2e8f0] p-[5px]">
      <label class="relative block"><input class="peer absolute inset-0 m-0 size-full cursor-pointer opacity-0" type="radio" name="tv-pos" id="tp-tl" value="top-left"><span class="block rounded-lg px-1.5 py-[7px] text-center text-[.8125rem] font-semibold text-[#475569] transition-colors duration-150 peer-checked:bg-white peer-checked:text-[#3730a3] peer-checked:shadow-[0_1px_3px_rgba(15,23,42,.16)] peer-focus-visible:outline-2 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Top left</span></label>
      <label class="relative block"><input class="peer absolute inset-0 m-0 size-full cursor-pointer opacity-0" type="radio" name="tv-pos" id="tp-tc" value="top-center"><span class="block rounded-lg px-1.5 py-[7px] text-center text-[.8125rem] font-semibold text-[#475569] transition-colors duration-150 peer-checked:bg-white peer-checked:text-[#3730a3] peer-checked:shadow-[0_1px_3px_rgba(15,23,42,.16)] peer-focus-visible:outline-2 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Top centre</span></label>
      <label class="relative block"><input class="peer absolute inset-0 m-0 size-full cursor-pointer opacity-0" type="radio" name="tv-pos" id="tp-tr" value="top-right"><span class="block rounded-lg px-1.5 py-[7px] text-center text-[.8125rem] font-semibold text-[#475569] transition-colors duration-150 peer-checked:bg-white peer-checked:text-[#3730a3] peer-checked:shadow-[0_1px_3px_rgba(15,23,42,.16)] peer-focus-visible:outline-2 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Top right</span></label>
      <label class="relative block"><input class="peer absolute inset-0 m-0 size-full cursor-pointer opacity-0" type="radio" name="tv-pos" id="tp-bl" value="bottom-left"><span class="block rounded-lg px-1.5 py-[7px] text-center text-[.8125rem] font-semibold text-[#475569] transition-colors duration-150 peer-checked:bg-white peer-checked:text-[#3730a3] peer-checked:shadow-[0_1px_3px_rgba(15,23,42,.16)] peer-focus-visible:outline-2 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Bottom left</span></label>
      <label class="relative block"><input class="peer absolute inset-0 m-0 size-full cursor-pointer opacity-0" type="radio" name="tv-pos" id="tp-bc" value="bottom-center"><span class="block rounded-lg px-1.5 py-[7px] text-center text-[.8125rem] font-semibold text-[#475569] transition-colors duration-150 peer-checked:bg-white peer-checked:text-[#3730a3] peer-checked:shadow-[0_1px_3px_rgba(15,23,42,.16)] peer-focus-visible:outline-2 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Bottom centre</span></label>
      <label class="relative block"><input class="peer absolute inset-0 m-0 size-full cursor-pointer opacity-0" type="radio" name="tv-pos" id="tp-br" value="bottom-right" checked><span class="block rounded-lg px-1.5 py-[7px] text-center text-[.8125rem] font-semibold text-[#475569] transition-colors duration-150 peer-checked:bg-white peer-checked:text-[#3730a3] peer-checked:shadow-[0_1px_3px_rgba(15,23,42,.16)] peer-focus-visible:outline-2 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Bottom right</span></label>
    </div>
  </fieldset>
  <div class="relative h-[280px] overflow-hidden rounded-2xl border border-[#d6dce5] bg-[#f8fafc]">
    <div class="absolute inset-0 p-[22px]" aria-hidden="true"><i class="mb-3 block h-4 w-[38%] rounded-full bg-[#e7ebf1]"></i><i class="mb-3 block h-2.5 w-[72%] rounded-full bg-[#e7ebf1]"></i><i class="mb-3 block h-2.5 w-[64%] rounded-full bg-[#e7ebf1]"></i><i class="mb-3 block h-2.5 w-[68%] rounded-full bg-[#e7ebf1]"></i></div>
    <div class="absolute right-4 bottom-4 w-[min(300px,calc(100%-32px))] group-has-[#tp-tl:checked]/pos:inset-[16px_auto_auto_16px] group-has-[#tp-tc:checked]/pos:inset-[16px_auto_auto_50%] group-has-[#tp-tc:checked]/pos:-translate-x-1/2 group-has-[#tp-tr:checked]/pos:inset-[16px_16px_auto_auto] group-has-[#tp-bl:checked]/pos:inset-[auto_auto_16px_16px] group-has-[#tp-bc:checked]/pos:inset-[auto_auto_16px_50%] group-has-[#tp-bc:checked]/pos:-translate-x-1/2" role="status">
      <div class="flex items-center gap-2.5 rounded-[14px] bg-[#1e1b4b] py-2 pr-2 pl-4 text-sm text-[#e0e7ff] shadow-[0_14px_30px_-14px_rgba(30,27,75,.7)]"><svg class="size-[18px] flex-none text-[#86efac]" aria-hidden="true"><use href="#i-link"/></svg><p class="min-w-0">Link copied to clipboard</p></div>
    </div>
  </div>
</div></div>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#5b6474] uppercase">Compact snackbars</span>
<div class="flex flex-wrap items-start justify-center gap-4 rounded-2xl bg-[#e9edf4] bg-[radial-gradient(#cfd6e2_1px,transparent_1px)] bg-size-[18px_18px] px-5 py-8">
  <div class="inline-flex max-w-full items-center gap-2.5 rounded-full bg-[#1e1b4b] py-2 pr-2 pl-4 text-sm text-[#e0e7ff] shadow-[0_14px_30px_-14px_rgba(30,27,75,.7)]" role="status" data-toast><svg class="size-[18px] flex-none text-[#86efac]" aria-hidden="true"><use href="#i-ok"/></svg><p class="min-w-0">Draft saved</p><button class="grid size-7 flex-none cursor-pointer place-items-center rounded-full text-[#c7d2fe] 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" data-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button></div>
  <div class="inline-flex max-w-full items-center gap-2.5 rounded-full bg-[#1e1b4b] py-2 pr-2 pl-4 text-sm text-[#e0e7ff] shadow-[0_14px_30px_-14px_rgba(30,27,75,.7)]" role="status" data-toast><svg class="size-[18px] flex-none text-[#fca5a5]" aria-hidden="true"><use href="#i-trash"/></svg><p class="min-w-0">3 photos moved to Trash</p><button class="h-[30px] cursor-pointer rounded-full bg-[rgba(165,180,252,.16)] px-3 text-[.8125rem] font-bold text-[#c7d2fe] transition-colors duration-150 hover:bg-[rgba(165,180,252,.28)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#a5b4fc] motion-reduce:transition-none" type="button" data-close>Undo</button><button class="grid size-7 flex-none cursor-pointer place-items-center rounded-full text-[#c7d2fe] 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" data-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button></div>
</div></div>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#5b6474] uppercase">Message toasts with avatar</span>
<div class="flex flex-wrap items-start justify-center gap-4 rounded-2xl bg-[#e9edf4] bg-[radial-gradient(#cfd6e2_1px,transparent_1px)] bg-size-[18px_18px] px-5 py-8">
  <div class="flex w-[min(360px,100%)] gap-3 rounded-2xl border border-[#e2e8f0] bg-white py-3.5 pr-3 pl-3.5 text-sm leading-[1.45] text-[#475569] shadow-[0_18px_36px_-18px_rgba(15,23,42,.35)]" role="status" data-toast>
    <span class="relative grid size-10 flex-none place-items-center rounded-full bg-[linear-gradient(135deg,#f472b6,#8b5cf6)] text-[.8125rem] font-extrabold text-white" aria-hidden="true">MO<i class="absolute -right-px -bottom-px size-3 rounded-full border-2 border-white bg-[#22c55e]"></i></span>
    <div class="min-w-0 flex-1">
      <p class="flex items-baseline gap-2"><b class="text-[.9375rem] text-[#0f172a]">Maya Okafor</b><time class="text-xs text-[#64748b]" datetime="2026-10-14T15:12">now</time></p>
      <p class="mt-0.5 text-[#334155]">Can you check the Q3 deck before the 4pm call? Slide 12 still has last year's numbers.</p>
      <div class="mt-2.5 flex gap-2"><button class="h-8 cursor-pointer rounded-lg border border-[#4f46e5] bg-[#4f46e5] px-3 text-[.8125rem] font-[650] text-white transition-colors duration-150 hover:bg-[#4338ca] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none" type="button">Reply</button><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-[#4f46e5] motion-reduce:transition-none" type="button" data-close>Mark as read</button></div>
    </div>
    <button class="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-[#4f46e5] motion-reduce:transition-none" type="button" aria-label="Dismiss message from Maya Okafor" data-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button>
  </div>
  <div class="flex w-[min(360px,100%)] gap-3 rounded-2xl border border-[#e2e8f0] bg-white py-3.5 pr-3 pl-3.5 text-sm leading-[1.45] text-[#475569] shadow-[0_18px_36px_-18px_rgba(15,23,42,.35)]" role="status" data-toast>
    <span class="relative grid size-10 flex-none place-items-center rounded-full bg-[linear-gradient(135deg,#2dd4bf,#0284c7)] text-[.8125rem] font-extrabold text-white" aria-hidden="true">LB</span>
    <div class="min-w-0 flex-1">
      <p class="flex items-baseline gap-2"><b class="text-[.9375rem] text-[#0f172a]">Leo Brandt</b><time class="text-xs text-[#64748b]" datetime="2026-10-14T15:08">4 min ago</time></p>
      <p class="mt-0.5 text-[#334155]">Commented on <strong>Homepage v3</strong>: “Hero copy reads well. Ship it.”</p>
    </div>
    <button class="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-[#4f46e5] motion-reduce:transition-none" type="button" aria-label="Dismiss comment from Leo Brandt" data-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button>
  </div>
</div></div>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#5b6474] uppercase">Promise toast: loading, then success</span>
<div class="flex flex-wrap items-start justify-center gap-4 rounded-2xl bg-[#e9edf4] bg-[radial-gradient(#cfd6e2_1px,transparent_1px)] bg-size-[18px_18px] px-5 py-8">
  <div class="flex w-[min(360px,100%)] flex-col gap-2.5" aria-live="polite" data-promise-stack>
    <div class="group/p flex items-center gap-3 rounded-[14px] border border-[#e2e8f0] bg-white py-3 pr-2.5 pl-3.5 text-sm leading-[1.45] text-[#475569] shadow-[0_18px_36px_-18px_rgba(15,23,42,.35)]" data-state="done" data-toast data-promise>
      <span class="relative size-7 flex-none" aria-hidden="true"><span class="absolute inset-[3px] animate-[spin_.8s_linear_infinite] rounded-full border-[2.5px] border-[#e0e7ff] border-t-[#4f46e5] group-data-[state=done]/p:hidden motion-reduce:animate-[spin_2.4s_linear_infinite]"></span><span class="absolute inset-0 hidden place-items-center rounded-full bg-[#dcfce7] text-[#16a34a] group-data-[state=done]/p:grid group-data-[state=done]/p:animate-pop motion-reduce:animate-none"><svg class="size-4"><use href="#i-ok"/></svg></span></span>
      <div class="min-w-0 flex-1"><p class="font-[650] text-[#0f172a]" data-title>Invoice uploaded</p><p class="mt-px" data-text>invoice-0918.pdf was added to September expenses.</p></div>
      <button class="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-[#4f46e5] motion-reduce:transition-none" type="button" aria-label="Dismiss" data-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button>
    </div>
    <div class="group/p flex items-center gap-3 rounded-[14px] border border-[#e2e8f0] bg-white py-3 pr-2.5 pl-3.5 text-sm leading-[1.45] text-[#475569] shadow-[0_18px_36px_-18px_rgba(15,23,42,.35)]" data-state="loading" data-toast data-promise data-run>
      <span class="relative size-7 flex-none" aria-hidden="true"><span class="absolute inset-[3px] animate-[spin_.8s_linear_infinite] rounded-full border-[2.5px] border-[#e0e7ff] border-t-[#4f46e5] group-data-[state=done]/p:hidden motion-reduce:animate-[spin_2.4s_linear_infinite]"></span><span class="absolute inset-0 hidden place-items-center rounded-full bg-[#dcfce7] text-[#16a34a] group-data-[state=done]/p:grid group-data-[state=done]/p:animate-pop motion-reduce:animate-none"><svg class="size-4"><use href="#i-ok"/></svg></span></span>
      <div class="min-w-0 flex-1"><p class="font-[650] text-[#0f172a]" data-title>Uploading invoice-0925.pdf</p><p class="mt-px" data-text>2.4 MB, a few seconds left</p></div>
      <button class="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-[#4f46e5] motion-reduce:transition-none" type="button" aria-label="Dismiss" data-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button>
    </div>
    <button class="h-8 cursor-pointer self-start rounded-lg border border-[#4f46e5] bg-[#4f46e5] px-3 text-[.8125rem] font-[650] text-white transition-colors duration-150 hover:bg-[#4338ca] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none" type="button" data-upload>Upload another invoice</button>
  </div>
  <template data-promise-template><div class="group/p flex items-center gap-3 rounded-[14px] border border-[#e2e8f0] bg-white py-3 pr-2.5 pl-3.5 text-sm leading-[1.45] text-[#475569] shadow-[0_18px_36px_-18px_rgba(15,23,42,.35)]" data-state="loading" data-toast data-promise><span class="relative size-7 flex-none" aria-hidden="true"><span class="absolute inset-[3px] animate-[spin_.8s_linear_infinite] rounded-full border-[2.5px] border-[#e0e7ff] border-t-[#4f46e5] group-data-[state=done]/p:hidden motion-reduce:animate-[spin_2.4s_linear_infinite]"></span><span class="absolute inset-0 hidden place-items-center rounded-full bg-[#dcfce7] text-[#16a34a] group-data-[state=done]/p:grid group-data-[state=done]/p:animate-pop motion-reduce:animate-none"><svg class="size-4"><use href="#i-ok"/></svg></span></span><div class="min-w-0 flex-1"><p class="font-[650] text-[#0f172a]" data-title></p><p class="mt-px" data-text></p></div><button class="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-[#4f46e5] motion-reduce:transition-none" type="button" aria-label="Dismiss" data-close><svg class="size-[15px]" aria-hidden="true"><use href="#i-x"/></svg></button></div></template>
</div></div>
CSS
@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.