{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast-stack",
  "type": "registry:block",
  "title": "Toast Stack",
  "description": "Stacked toasts with a real spring under every one: the stack peeks two deep, expands into a list on hover or focus, and swipes away with velocity. Promise toasts close their spinner into a check.",
  "docs": "Toast Stack was added to public/vividsites/toast-stack.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/toast-stack",
  "categories": [
    "ui",
    "loaders-cursors-feedback"
  ],
  "files": [
    {
      "path": "components/vividsites/toast-stack.html",
      "type": "registry:file",
      "target": "public/vividsites/toast-stack.html",
      "content": "<!--\n  vs-toast-stack: stacked toasts. Collapsed, the newest toast sits in front with up to two behind it\n  (scaled .95 / .90 and peeking out by a fixed amount, whatever their heights: a taller toast behind\n  a short one is trimmed to the front one's edge, never poking out). Hover, focus or tap expands the\n  stack into a list; every toast rides its own spring (position, scale, opacity), so any change\n  mid-flight is picked up from where it is, with its velocity. Swipe a toast away (mouse, pen or\n  touch): the release velocity decides, a flick is enough, and dragging the wrong way rubber-bands.\n  Timers pause while the stack is hovered or focused and while the tab is hidden.\n\n  JS API (loads once, creates the region if you did not paste the <section>):\n    VsToast('Draft saved')                                   default toast, no icon\n    VsToast('Published', { type: 'success', description: 'Live on 3 domains' })\n    VsToast('Upload failed', { type: 'error' })\n    VsToast('Message archived', { description: '…', action: { label: 'Undo', onClick() {} }, duration: 6000 })\n    VsToast.promise(fetch('/save'), { loading: 'Saving…', success: (r) => 'Saved', error: 'Could not save' })\n      the spinner closes into a check (or a cross) on a spring when the promise settles\n    VsToast.update(id, { message, type, description })       VsToast returns the id\n    VsToast.dismiss(id)  /  VsToast.dismiss()                one, or all\n  Options: type ('default' | 'success' | 'error' | 'loading'), description, action {label, onClick},\n    duration (ms, default 4000; Infinity to keep it), onDismiss(id).\n\n  Try button: the <button class=\"vs-toast-stack-try\"> below is a ready-made trigger so the paste works\n    on its own. Each press posts the next sample (success, promise -> resolved, action). Keep it,\n    restyle it (--vs-toast-stack-try-bg / -ink / -radius on the button) or delete it and call VsToast()\n    from your own code.\n\n  Region attributes (read live, change them at any time):\n    data-position   bottom-right | bottom-center | bottom-left | top-center | top-right | top-left\n    data-theme      light | dark                      the toasts' own surface\n    data-expand     hover (default) | always          always = permanently listed\n  Keyboard: every toast is focusable; Alt+T jumps to the newest, Escape or Delete dismisses the\n  focused one and focus moves on (or back to where it came from). The list is an aria-live=\"polite\"\n  region. prefers-reduced-motion: toasts fade in place; nothing slides or bounces.\n\n  Variables (on .vs-toast-stack):\n    --vs-toast-stack-width     380px       --vs-toast-stack-radius   14px\n    --vs-toast-stack-size      14px        type size; every inner measure scales with it\n    --vs-toast-stack-gap       12px        space between toasts when expanded\n    --vs-toast-stack-peek      14px        how far each toast behind peeks out when collapsed\n    --vs-toast-stack-offset    32px        distance from the viewport edge\n    --vs-toast-stack-font      \"Onest\", system-ui, sans-serif (load Onest 400;500)\n    --vs-toast-stack-success   #16a34a     --vs-toast-stack-error   #e5484d\n    surface/ink per theme: --vs-toast-stack-bg, -ink, -mute, -line, -action-bg, -action-ink\n-->\n<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Onest:wght@400;500&display=swap\">\n<style>\n  .vs-toast-stack {\n    --vs-toast-stack-width: 380px;\n    --vs-toast-stack-size: 14px;\n    --vs-toast-stack-radius: 14px;\n    --vs-toast-stack-gap: 12px;\n    --vs-toast-stack-peek: 14px;\n    --vs-toast-stack-offset: 32px;\n    --vs-toast-stack-font: \"Onest\", system-ui, -apple-system, \"Segoe UI\", sans-serif;\n    --vs-toast-stack-success: #16a34a;\n    --vs-toast-stack-error: #e5484d;\n    --vs-toast-stack-bg: #ffffff;\n    --vs-toast-stack-ink: #17181b;\n    --vs-toast-stack-mute: rgba(23,24,27,.56);\n    --vs-toast-stack-line: rgba(17,18,20,.09);\n    --vs-toast-stack-hi: rgba(255,255,255,1);\n    --vs-toast-stack-action-bg: #17181b;\n    --vs-toast-stack-action-ink: #fff;\n    --vs-toast-stack-shadow:\n      0 0 0 .5px var(--vs-toast-stack-line),\n      0 1px 1px rgba(17,18,20,.04),\n      0 4px 8px -2px rgba(17,18,20,.06),\n      0 14px 28px -10px rgba(17,18,20,.16);\n    position: fixed; z-index: 999; box-sizing: border-box;\n    width: min(var(--vs-toast-stack-width), calc(100vw - 32px)); height: 0;\n    bottom: var(--vs-toast-stack-offset); left: 50%; margin-left: calc(min(var(--vs-toast-stack-width), calc(100vw - 32px)) / -2);\n    font-family: var(--vs-toast-stack-font); outline: none;\n    -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;\n  }\n  .vs-toast-stack[data-theme=\"dark\"] {\n    --vs-toast-stack-bg: #222327;\n    --vs-toast-stack-ink: #f1f1f2;\n    --vs-toast-stack-mute: rgba(241,241,242,.58);\n    --vs-toast-stack-line: rgba(255,255,255,.1);\n    --vs-toast-stack-hi: rgba(255,255,255,.1);\n    --vs-toast-stack-action-bg: #f1f1f2;\n    --vs-toast-stack-action-ink: #17181b;\n    --vs-toast-stack-success: #3ccf7c;\n    --vs-toast-stack-error: #ff6166;\n    --vs-toast-stack-shadow:\n      0 0 0 .5px rgba(0,0,0,.6),\n      0 1px 1px rgba(0,0,0,.2),\n      0 6px 12px -3px rgba(0,0,0,.3),\n      0 18px 36px -12px rgba(0,0,0,.5);\n  }\n  .vs-toast-stack[data-position^=\"top\"] { bottom: auto; top: var(--vs-toast-stack-offset); }\n  .vs-toast-stack[data-position$=\"right\"] { left: auto; margin-left: 0; right: var(--vs-toast-stack-offset); }\n  .vs-toast-stack[data-position$=\"left\"] { left: var(--vs-toast-stack-offset); margin-left: 0; }\n  @media (max-width: 600px) {\n    .vs-toast-stack { --vs-toast-stack-offset: 16px; }\n    .vs-toast-stack[data-position] { left: 50%; right: auto; margin-left: calc(min(var(--vs-toast-stack-width), calc(100vw - 32px)) / -2); }\n  }\n  .vs-toast-stack-list { list-style: none; margin: 0; padding: 0; position: relative; height: 0; }\n\n  .vs-toast-stack-toast {\n    position: absolute; left: 0; right: 0; bottom: 0; box-sizing: border-box;\n    transform-origin: 50% 100%; will-change: transform, opacity; opacity: 0;\n    border-radius: var(--vs-toast-stack-radius);\n    background: var(--vs-toast-stack-bg); color: var(--vs-toast-stack-ink);\n    box-shadow: var(--vs-toast-stack-shadow), inset 0 .5px 0 var(--vs-toast-stack-hi);\n    font-size: var(--vs-toast-stack-size); line-height: 1.35; font-weight: 400; letter-spacing: -.005em;\n    touch-action: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent;\n    outline: none; cursor: default;\n  }\n  .vs-toast-stack[data-position^=\"top\"] .vs-toast-stack-toast { bottom: auto; top: 0; transform-origin: 50% 0; }\n  /* bridges the gaps when expanded, so the pointer never \"falls through\" between toasts */\n  .vs-toast-stack-toast::before { content: \"\"; position: absolute; left: 0; right: 0; bottom: 100%; height: calc(var(--vs-toast-stack-gap) + 2px); }\n  .vs-toast-stack[data-position^=\"top\"] .vs-toast-stack-toast::before { bottom: auto; top: 100%; }\n  .vs-toast-stack-toast:focus-visible { box-shadow: var(--vs-toast-stack-shadow), 0 0 0 2px var(--vs-toast-stack-bg), 0 0 0 4px var(--vs-toast-stack-ink); }\n  .vs-toast-stack-toast.is-dragging { cursor: grabbing; }\n\n  .vs-toast-stack-inner { display: flex; align-items: center; gap: .86em; padding: 1.07em 1.14em; min-height: 1.86em; }\n  .vs-toast-stack-toast.has-desc .vs-toast-stack-inner { align-items: flex-start; }\n  .vs-toast-stack-text { flex: 1; min-width: 0; }\n  .vs-toast-stack-title { display: block; font-weight: 500; }\n  .vs-toast-stack-desc { display: block; margin-top: .14em; font-size: .93em; color: var(--vs-toast-stack-mute); }\n  .vs-toast-stack-desc:empty { display: none; }\n\n  .vs-toast-stack-icon { flex: none; width: 1.43em; height: 1.43em; overflow: visible; color: var(--vs-toast-stack-mute); }\n  .vs-toast-stack-toast[data-type=\"default\"] .vs-toast-stack-icon { display: none; }\n  .vs-toast-stack-toast[data-type=\"success\"] .vs-toast-stack-icon { color: var(--vs-toast-stack-success); }\n  .vs-toast-stack-toast[data-type=\"error\"] .vs-toast-stack-icon { color: var(--vs-toast-stack-error); }\n  .vs-toast-stack-ring { transform-box: fill-box; transform-origin: center; }\n  .vs-toast-stack-toast[data-spin] .vs-toast-stack-ring { animation: vs-toast-stack-spin .75s linear infinite; }\n  .vs-toast-stack-disc { transform-box: fill-box; transform-origin: center; }\n  @keyframes vs-toast-stack-spin { to { transform: rotate(360deg); } }\n\n  .vs-toast-stack-action {\n    flex: none; align-self: center; height: 2.24em; padding: 0 .88em; border: 0; border-radius: .64em; cursor: pointer;\n    font: 500 .89em/1 var(--vs-toast-stack-font); letter-spacing: 0;\n    background: var(--vs-toast-stack-action-bg); color: var(--vs-toast-stack-action-ink);\n    box-shadow: inset 0 .5px 0 rgba(255,255,255,.16), 0 1px 2px rgba(0,0,0,.14);\n    transition: opacity .15s cubic-bezier(.2,0,0,1), transform .15s cubic-bezier(.2,0,0,1);\n    -webkit-tap-highlight-color: transparent;\n  }\n  .vs-toast-stack-action:hover { opacity: .86; }\n  .vs-toast-stack-action:active { transform: scale(.95); }\n  .vs-toast-stack-action:focus-visible { outline: 2px solid var(--vs-toast-stack-ink); outline-offset: 2px; }\n\n  .vs-toast-stack-close {\n    position: absolute; top: -.5em; left: -.5em; width: 1.57em; height: 1.57em; padding: 0; border: 0; border-radius: 50%;\n    display: grid; place-items: center; cursor: pointer; color: var(--vs-toast-stack-mute);\n    background: var(--vs-toast-stack-bg); box-shadow: 0 0 0 .5px var(--vs-toast-stack-line), 0 1px 3px rgba(0,0,0,.12);\n    opacity: 0; transform: scale(.6); pointer-events: none;\n    transition: opacity .18s cubic-bezier(.2,0,0,1), transform .32s cubic-bezier(.34,1.56,.64,1), color .15s;\n  }\n  .vs-toast-stack-close svg { width: .71em; height: .71em; }\n  .vs-toast-stack-close:hover { color: var(--vs-toast-stack-ink); }\n  .vs-toast-stack-close:focus-visible { outline: 2px solid var(--vs-toast-stack-ink); outline-offset: 1px; }\n  @media (hover: hover) {\n    .vs-toast-stack-toast:hover .vs-toast-stack-close { opacity: 1; transform: none; pointer-events: auto; }\n  }\n  .vs-toast-stack-toast:focus-within .vs-toast-stack-close { opacity: 1; transform: none; pointer-events: auto; }\n  .vs-toast-stack-toast.is-back .vs-toast-stack-close, .vs-toast-stack-toast.is-dragging .vs-toast-stack-close { opacity: 0; pointer-events: none; }\n  @media (prefers-reduced-motion: reduce) {\n    .vs-toast-stack-toast[data-spin] .vs-toast-stack-ring { animation-duration: 2s; }\n  }\n  /* optional sample trigger (see header); safe to delete */\n  .vs-toast-stack-try {\n    --vs-toast-stack-try-bg: #17181b;\n    --vs-toast-stack-try-ink: #ffffff;\n    --vs-toast-stack-try-radius: 10px;\n    display: inline-flex; align-items: center; gap: .55em; height: 40px; padding: 0 18px; margin: 24px;\n    border: 0; border-radius: var(--vs-toast-stack-try-radius); cursor: pointer;\n    font: 500 14px/1 \"Onest\", system-ui, -apple-system, \"Segoe UI\", sans-serif; letter-spacing: -.005em;\n    color: var(--vs-toast-stack-try-ink); background: var(--vs-toast-stack-try-bg);\n    box-shadow: inset 0 .5px 0 rgba(255,255,255,.16), 0 1px 2px rgba(0,0,0,.12), 0 4px 12px -4px rgba(0,0,0,.22);\n    transition: transform .3s cubic-bezier(.34,1.56,.64,1), box-shadow .2s; -webkit-tap-highlight-color: transparent;\n  }\n  .vs-toast-stack-try svg { width: 16px; height: 16px; }\n  .vs-toast-stack-try:hover { box-shadow: inset 0 .5px 0 rgba(255,255,255,.16), 0 2px 4px rgba(0,0,0,.14), 0 8px 20px -6px rgba(0,0,0,.3); }\n  .vs-toast-stack-try:active { transform: scale(.96); transition-duration: .1s; }\n  .vs-toast-stack-try:focus-visible { outline: 2px solid var(--vs-toast-stack-try-bg); outline-offset: 3px; }\n</style>\n<section class=\"vs-toast-stack\" data-position=\"bottom-right\" data-theme=\"light\" data-expand=\"hover\" aria-label=\"Notifications (Alt+T)\" tabindex=\"-1\">\n  <ol class=\"vs-toast-stack-list\" aria-live=\"polite\" aria-relevant=\"additions text\"></ol>\n</section>\n<button class=\"vs-toast-stack-try\" type=\"button\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 11.5V7a4 4 0 0 1 8 0v4.5l1.2 1.2H2.8zM6.6 14.2a1.6 1.6 0 0 0 2.8 0\"/></svg>Show a toast</button>\n<script>\n(() => {\n  if (window.VsToast) return;\n  const MAX = 3, SCALE = .05, EDGE = 60;\n  const mq = matchMedia('(prefers-reduced-motion: reduce)');\n  const ICON = '<svg class=\"vs-toast-stack-icon\" viewBox=\"0 0 20 20\" fill=\"none\" aria-hidden=\"true\">' +\n    '<circle class=\"vs-toast-stack-ring\" cx=\"10\" cy=\"10\" r=\"8\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" pathLength=\"100\" stroke-dasharray=\"28 100\"/>' +\n    '<circle class=\"vs-toast-stack-disc\" cx=\"10\" cy=\"10\" r=\"10\" fill=\"currentColor\"/>' +\n    '<path class=\"vs-toast-stack-mark\" stroke=\"#fff\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" pathLength=\"1\" stroke-dasharray=\"1\" stroke-dashoffset=\"1\"/></svg>';\n  const MARK = { success: 'M6 10.3l2.7 2.7L14 7.5', error: 'M7.2 7.2l5.6 5.6M12.8 7.2l-5.6 5.6' };\n  const CLOSE = '<svg viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M2 2l6 6M8 2l-6 6\"/></svg>';\n\n  // semi-implicit Euler spring; stepped in fixed sub-steps so it is frame-rate independent\n  class Spring {\n    constructor(v, k, c, eps) { this.v = this.t = v; this.vel = 0; this.k = k; this.c = c; this.eps = eps; }\n    step(h) { this.vel += (-this.k * (this.v - this.t) - this.c * this.vel) * h; this.v += this.vel * h; }\n    get rest() { return Math.abs(this.v - this.t) < this.eps && Math.abs(this.vel) < this.eps * 10; }\n    snap(v) { this.v = this.t = v; this.vel = 0; }\n  }\n\n  let root, list, raf = 0, last = 0, seq = 0, hovering = false, focusing = false, tapped = false, returnFocus = null;\n  const toasts = []; // newest first\n\n  const cfg = () => {\n    const pos = root.getAttribute('data-position') || 'bottom-right';\n    return { top: pos.startsWith('top'), xs: pos.endsWith('right') ? [1] : pos.endsWith('left') ? [-1] : [-1, 1],\n      always: root.getAttribute('data-expand') === 'always' };\n  };\n  const cssNum = (k, fb) => { const v = parseFloat(getComputedStyle(root).getPropertyValue(k)); return isNaN(v) ? fb : v; };\n  const expanded = () => cfg().always || hovering || focusing || tapped;\n  const paused = () => hovering || focusing || tapped || document.hidden || toasts.some((t) => t.drag);\n\n  function mount() {\n    root = document.querySelector('.vs-toast-stack');\n    if (!root) {\n      root = document.createElement('section'); root.className = 'vs-toast-stack';\n      root.setAttribute('data-position', 'bottom-right'); root.setAttribute('aria-label', 'Notifications (Alt+T)'); root.tabIndex = -1;\n      root.innerHTML = '<ol class=\"vs-toast-stack-list\" aria-live=\"polite\" aria-relevant=\"additions text\"></ol>';\n      document.body.appendChild(root);\n    }\n    list = root.querySelector('.vs-toast-stack-list');\n    root.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') { hovering = true; sync(); } });\n    root.addEventListener('pointerleave', (e) => { if (e.pointerType === 'mouse') { hovering = false; sync(); } });\n    root.addEventListener('focusin', (e) => { if (e.target.matches(':focus-visible')) { focusing = true; sync(); } });\n    root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget)) { focusing = false; sync(); } });\n    document.addEventListener('pointerdown', (e) => { if (tapped && !root.contains(e.target)) { tapped = false; sync(); } });\n    document.addEventListener('visibilitychange', sync);\n    document.addEventListener('keydown', (e) => {\n      if (e.altKey && e.code === 'KeyT' && toasts.length) {\n        e.preventDefault(); const t = toasts.find((t) => !t.gone); if (!t) return;\n        if (!root.contains(document.activeElement)) returnFocus = document.activeElement;\n        t.el.focus();\n      }\n    });\n    new MutationObserver(() => { layout(); kick(); }).observe(root, { attributes: true, attributeFilter: ['data-position', 'data-expand'] });\n    const ro = new ResizeObserver((es) => { for (const e of es) { const t = toasts.find((t) => t.el === e.target); if (t) t.h = t.el.offsetHeight; } layout(); kick(); });\n    root._ro = ro;\n  }\n\n  // ── layout: targets for every toast from the stack's current state ──\n  function layout() {\n    const c = cfg(), sign = c.top ? 1 : -1, exp = expanded();\n    const gap = cssNum('--vs-toast-stack-gap', 12), peek = cssNum('--vs-toast-stack-peek', 14);\n    const live = toasts.filter((t) => !t.gone);\n    const H0 = live.length ? live[0].h : 0;\n    let acc = 0;\n    live.forEach((t, i) => {\n      const vis = i < MAX, j = Math.min(i, MAX); t.idx = i;\n      if (exp) { t.y.t = sign * acc; t.s.t = 1; acc += t.h + gap; }\n      else {\n        const s = 1 - j * SCALE; t.s.t = s;\n        // put this toast's outer edge exactly j*peek beyond the front toast's, whatever its own height\n        t.y.t = i === 0 ? 0 : sign * (H0 + j * peek - t.h * s);\n      }\n      if (!t.drag) t.o.t = vis ? 1 : 0;\n      t.co.t = exp || i === 0 ? 1 : 0;\n      t.el.style.zIndex = 100 - i;\n      t.el.classList.toggle('is-back', !exp && i > 0);\n      t.el.style.pointerEvents = vis ? '' : 'none';\n      t.el.toggleAttribute('inert', !vis);\n      if (mq.matches) { t.y.snap(t.y.t); t.s.snap(t.s.t); }\n    });\n    root.classList.toggle('is-expanded', exp);\n    timers();\n  }\n  function sync() { if (!toasts.some((t) => !t.gone)) { hovering = focusing = tapped = false; } layout(); kick(); }\n\n  // ── timers: paused while hovered / focused / tapped open / hidden tab / dragging ──\n  function timers() {\n    const p = paused(), now = performance.now();\n    for (const t of toasts) {\n      if (t.gone || !isFinite(t.remain)) { clearTimeout(t.timer); t.timer = 0; continue; }\n      if (p && t.timer) { clearTimeout(t.timer); t.timer = 0; t.remain -= now - t.started; }\n      else if (!p && !t.timer) { t.started = now; t.timer = setTimeout(() => dismiss(t.id, 'timeout'), Math.max(0, t.remain)); }\n    }\n  }\n\n  // ── animation loop: runs only while something moves ──\n  function kick() { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } }\n  function tick(now) {\n    const dt = Math.min(.05, (now - last) / 1000); last = now;\n    const n = Math.max(1, Math.ceil(dt / (1 / 240))), h = dt / n;\n    const c = cfg(), sign = c.top ? 1 : -1;\n    let busy = false;\n    for (let i = toasts.length - 1; i >= 0; i--) {\n      const t = toasts[i];\n      const sp = t.drag ? [t.y, t.s, t.o, t.co, t.ic] : [t.x, t.dy, t.y, t.s, t.o, t.co, t.ic];\n      for (let k = 0; k < n; k++) for (const s of sp) s.step(h);\n      if (mq.matches) { t.x.snap(t.x.t); t.dy.snap(t.dy.t); t.ic.snap(t.ic.t); }\n      paint(t, sign);\n      if (t.gone && t.o.v < .015 && (t.o.t === 0)) { t.el.remove(); root._ro.unobserve(t.el); toasts.splice(i, 1); continue; }\n      if (t.drag || !sp.every((s) => s.rest)) busy = true;\n    }\n    raf = busy ? requestAnimationFrame(tick) : 0;\n  }\n  function paint(t, sign) {\n    const y = t.y.v + t.dy.v, s = t.s.v;\n    t.el.style.transform = `translate3d(${t.x.v.toFixed(2)}px,${y.toFixed(2)}px,0) scale(${s.toFixed(4)})`;\n    t.el.style.opacity = Math.max(0, Math.min(1, t.o.v)).toFixed(3);\n    t.inner.style.opacity = Math.max(0, Math.min(1, t.co.v)).toFixed(3);\n    // a toast behind that is taller than the front one would poke out past its edge: trim it there\n    const over = -sign * t.y.v, cut = !t.gone && t.idx > 0 && over > .5 ? over / s + 2 : 0;\n    const clip = cut ? (sign < 0 ? `inset(-${EDGE}px -${EDGE}px ${cut.toFixed(1)}px -${EDGE}px)` : `inset(${cut.toFixed(1)}px -${EDGE}px -${EDGE}px -${EDGE}px)`) : '';\n    if (clip !== t.clip) { t.el.style.clipPath = clip; t.clip = clip; }\n    // icon: the spinner closes into a disc that springs up, then the mark draws in\n    if (t.icp !== t.ic.v) {\n      const p = t.ic.v; t.icp = p;\n      t.disc.style.transform = `scale(${Math.max(0, p).toFixed(4)})`;\n      t.mark.setAttribute('stroke-dashoffset', (1 - Math.max(0, Math.min(1, (p - .35) / .6))).toFixed(3));\n      t.ring.setAttribute('stroke-dasharray', `${(28 + 72 * Math.min(1, Math.max(0, p) * 1.6)).toFixed(1)} 100`);\n      t.ring.style.opacity = t.spun ? Math.max(0, 1 - p * 1.4).toFixed(3) : 0;\n    }\n  }\n\n  // ── build one toast ──\n  function create(message, o = {}) {\n    if (!root) mount();\n    const id = o.id != null ? o.id : ++seq;\n    const el = document.createElement('li');\n    el.className = 'vs-toast-stack-toast'; el.tabIndex = 0;\n    el.innerHTML = `<div class=\"vs-toast-stack-inner\">${ICON}<div class=\"vs-toast-stack-text\"><span class=\"vs-toast-stack-title\"></span><span class=\"vs-toast-stack-desc\"></span></div></div>` +\n      `<button type=\"button\" class=\"vs-toast-stack-close\" aria-label=\"Dismiss notification\">${CLOSE}</button>`;\n    const t = { id, el, inner: el.firstElementChild, h: 0, gone: false, drag: null, timer: 0, clip: '', icp: -1,\n      x: new Spring(0, 380, 32, .05), dy: new Spring(0, 380, 32, .05), y: new Spring(0, 420, 38, .05),\n      s: new Spring(1, 420, 38, .0003), o: new Spring(0, 320, 36, .002), co: new Spring(1, 320, 36, .002), ic: new Spring(0, 340, 15, .001),\n      onDismiss: o.onDismiss };\n    t.ring = el.querySelector('.vs-toast-stack-ring'); t.disc = el.querySelector('.vs-toast-stack-disc'); t.mark = el.querySelector('.vs-toast-stack-mark');\n    t.title = el.querySelector('.vs-toast-stack-title'); t.desc = el.querySelector('.vs-toast-stack-desc');\n    fill(t, message, o, true);\n    el.querySelector('.vs-toast-stack-close').addEventListener('click', () => dismiss(id, 'close'));\n    el.addEventListener('keydown', (e) => {\n      if (e.target !== el) return;\n      if (e.key === 'Escape' || e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); dismiss(id, 'key', true); }\n    });\n    bindSwipe(t);\n    list.prepend(el);\n    t.h = el.offsetHeight;\n    const sign = cfg().top ? 1 : -1;\n    t.y.snap(-sign * Math.min(t.h, 90) * .9); t.o.snap(0);\n    if (mq.matches) t.y.snap(0);\n    toasts.unshift(t);\n    root._ro.observe(el);\n    layout(); paint(t, sign); kick();\n    return id;\n  }\n\n  function fill(t, message, o, first) {\n    const type = o.type || (first ? 'default' : t.type);\n    const prev = t.type; t.type = type;\n    t.el.setAttribute('data-type', type);\n    t.el.toggleAttribute('data-spin', type === 'loading');\n    if (type === 'loading') { t.spun = true; t.ic.snap(0); }\n    if (type === 'success' || type === 'error') {\n      t.mark.setAttribute('d', MARK[type]);\n      if (prev !== type) { t.ic.snap(0); t.ic.t = 0; setTimeout(() => { t.ic.t = 1; kick(); }, first ? 140 : 0); }\n    }\n    if (message != null && t.title.textContent !== String(message)) {\n      const had = !first && t.title.textContent; t.title.textContent = message;\n      if (had && !mq.matches) t.title.animate([{ opacity: 0, filter: 'blur(3px)', transform: 'translateY(3px)' }, { opacity: 1, filter: 'blur(0)', transform: 'none' }], { duration: 420, easing: 'cubic-bezier(.16,1,.3,1)' });\n    }\n    if ('description' in o) t.desc.textContent = o.description || '';\n    t.el.classList.toggle('has-desc', !!t.desc.textContent);\n    if (o.action && !t.el.querySelector('.vs-toast-stack-action')) {\n      const b = document.createElement('button'); b.type = 'button'; b.className = 'vs-toast-stack-action'; b.textContent = o.action.label;\n      b.addEventListener('click', (e) => { o.action.onClick && o.action.onClick(e); if (!e.defaultPrevented) dismiss(t.id, 'action'); });\n      t.inner.appendChild(b);\n    }\n    const dur = o.duration != null ? o.duration : type === 'loading' ? Infinity : o.action ? 6000 : 4000;\n    clearTimeout(t.timer); t.timer = 0; t.remain = dur;\n    if (t.h) { t.h = t.el.offsetHeight; }\n  }\n\n  // ── swipe: pointer events, axis lock, rubber band the wrong way, velocity decides ──\n  function bindSwipe(t) {\n    const el = t.el;\n    const rb = (d) => { const m = 1 - 1 / (Math.abs(d) * .55 / 70 + 1); return Math.sign(d) * m * 70; };\n    el.addEventListener('pointerdown', (e) => {\n      if (e.button !== 0 || t.gone || e.target.closest('button')) return;\n      t.drag = { x0: e.clientX, y0: e.clientY, axis: null, d: 0, samples: [[e.timeStamp, 0]], id: e.pointerId, moved: false, type: e.pointerType };\n    });\n    el.addEventListener('pointermove', (e) => {\n      const d = t.drag; if (!d || e.pointerId !== d.id) return;\n      const dx = e.clientX - d.x0, dy = e.clientY - d.y0;\n      if (!d.axis) {\n        if (Math.hypot(dx, dy) < 5) return;\n        d.axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y'; d.moved = true;\n        el.setPointerCapture(e.pointerId); el.classList.add('is-dragging'); layout();\n      }\n      const c = cfg(), raw = d.axis === 'x' ? dx : dy;\n      const ok = d.axis === 'x' ? c.xs.includes(Math.sign(raw)) : Math.sign(raw) === (c.top ? -1 : 1);\n      const v = ok ? raw : rb(raw);\n      d.d = raw; d.ok = ok;\n      d.samples.push([e.timeStamp, raw]); while (d.samples.length > 2 && e.timeStamp - d.samples[0][0] > 90) d.samples.shift();\n      const w = el.offsetWidth;\n      if (d.axis === 'x') { t.x.snap(v); t.dy.snap(0); } else { t.dy.snap(v); t.x.snap(0); }\n      t.o.t = ok ? Math.max(.15, 1 - Math.abs(raw) / (w * 1.15)) : 1; t.o.snap(t.o.t);\n      paint(t, cfg().top ? 1 : -1); kick();\n    });\n    const end = (e) => {\n      const d = t.drag; if (!d || e.pointerId !== d.id) return;\n      t.drag = null; el.classList.remove('is-dragging');\n      if (!d.moved) {\n        if (d.type !== 'mouse') { tapped = !tapped; }\n        sync(); return;\n      }\n      const s = d.samples, a = s[0], b = s[s.length - 1];\n      const vel = (b[1] - a[1]) / Math.max(1, b[0] - a[0]); // px per ms\n      const w = el.offsetWidth, size = d.axis === 'x' ? w : t.h;\n      const dir = Math.sign(d.d) || Math.sign(vel);\n      const c = cfg();\n      const allowed = d.axis === 'x' ? c.xs.includes(dir) : dir === (c.top ? -1 : 1);\n      const fling = Math.abs(vel) > .45 && Math.sign(vel) === dir;\n      if (e.type === 'pointerup' && allowed && (Math.abs(d.d) > size * (d.axis === 'x' ? .38 : .5) || fling)) {\n        const sp = d.axis === 'x' ? t.x : t.dy;\n        sp.t = dir * (d.axis === 'x' ? w + 140 : t.h + 140); sp.vel = Math.max(Math.abs(vel) * 1000, 1100) * dir;\n        sp.k = 240; sp.c = 26;\n        dismiss(t.id, 'swipe');\n      } else {\n        const sp = d.axis === 'x' ? t.x : t.dy;\n        sp.t = 0; sp.vel = d.ok ? vel * 1000 : 0;\n        layout();\n      }\n      kick();\n    };\n    el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);\n  }\n\n  function dismiss(id, why, keyboard) {\n    if (id == null) { toasts.filter((t) => !t.gone).forEach((t, i) => setTimeout(() => dismiss(t.id, 'all'), i * 70)); return; }\n    const t = toasts.find((t) => t.id === id && !t.gone); if (!t) return;\n    const hadFocus = t.el.contains(document.activeElement);\n    const live = toasts.filter((x) => !x.gone), idx = live.indexOf(t);\n    t.gone = true; clearTimeout(t.timer); t.timer = 0; t.drag = null;\n    t.el.setAttribute('aria-hidden', 'true'); t.el.style.pointerEvents = 'none';\n    const sign = cfg().top ? 1 : -1;\n    t.o.t = 0; t.o.k = why === 'swipe' ? 150 : 260; t.o.c = why === 'swipe' ? 25 : 34;\n    if (why !== 'swipe') { t.s.t = t.s.v * .94; if (idx === 0) t.y.t = t.y.v - sign * t.h * .35; }\n    if (hadFocus) {\n      const next = toasts.find((x) => !x.gone);\n      if (next) next.el.focus(); else if (returnFocus && returnFocus.focus) { returnFocus.focus(); returnFocus = null; } else root.focus();\n    }\n    t.onDismiss && t.onDismiss(t.id, why);\n    sync();\n  }\n\n  function VsToast(message, o) { return create(message, o || {}); }\n  VsToast.success = (m, o) => create(m, { ...o, type: 'success' });\n  VsToast.error = (m, o) => create(m, { ...o, type: 'error' });\n  VsToast.dismiss = (id) => dismiss(id, 'api');\n  VsToast.update = (id, o = {}) => {\n    const t = toasts.find((t) => t.id === id && !t.gone); if (!t) return;\n    fill(t, o.message, o, false); layout(); kick();\n  };\n  VsToast.promise = (p, o = {}) => {\n    const id = create(o.loading || 'Loading…', { type: 'loading', description: o.description, duration: Infinity });\n    const val = (v, x) => (typeof v === 'function' ? v(x) : v);\n    Promise.resolve(typeof p === 'function' ? p() : p).then(\n      (r) => VsToast.update(id, { type: 'success', message: val(o.success, r) || 'Done', description: val(o.successDescription, r), duration: o.duration }),\n      (err) => VsToast.update(id, { type: 'error', message: val(o.error, err) || 'Something went wrong', description: val(o.errorDescription, err), duration: o.duration })\n    );\n    return id;\n  };\n  window.VsToast = VsToast;\n  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => { if (!root) mount(); }); else mount();\n})();\n</script>\n<script>\n  // sample trigger (optional): every .vs-toast-stack-try posts the next sample toast\n  if (!window.__vsToastTry) window.__vsToastTry = document.addEventListener('click', (e) => {\n    const b = e.target.closest('.vs-toast-stack-try'); if (!b || !window.VsToast) return;\n    const i = (+b.dataset.n || 0); b.dataset.n = i + 1;\n    [\n      () => VsToast('Changes published', { type: 'success', description: 'Live on 3 domains' }),\n      () => VsToast.promise(new Promise((r) => setTimeout(r, 1600)), { loading: 'Uploading field-notes.pdf', description: 'About 2 seconds left', success: 'Uploaded field-notes.pdf', successDescription: '2.4 MB · synced to 3 devices' }),\n      () => VsToast('Message archived', { description: 'Moved out of Inbox', action: { label: 'Undo', onClick: () => setTimeout(() => VsToast('Message restored', { type: 'success' }), 120) } })\n    ][i % 3]();\n  }) || true;\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Loaders, cursors & feedback",
    "tags": [
      "toast",
      "notification",
      "sonner",
      "swipe",
      "spring",
      "stack",
      "promise",
      "aria-live"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Onest:wght@400;500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#16a34a",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/toast-stack/index.html",
    "thumbnail": "https://vividsites.app/api/component/toast-stack/thumb.jpg",
    "video": "https://vividsites.app/api/component/toast-stack/thumb.mp4",
    "page": "https://vividsites.app/c/toast-stack"
  }
}