{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spring-tooltip",
  "type": "registry:block",
  "title": "Spring Tooltip",
  "description": "One tooltip shared by a group of triggers: it glides between them on a position spring, morphs its width and slides the label in the direction you moved, leaning with its own velocity. Flips and shifts at screen edges with the arrow still on the anchor; keyboard, Escape and long-press touch included.",
  "docs": "Spring Tooltip was added to public/vividsites/spring-tooltip.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/spring-tooltip",
  "categories": [
    "ui",
    "loaders-cursors-feedback"
  ],
  "files": [
    {
      "path": "components/vividsites/spring-tooltip.html",
      "type": "registry:file",
      "target": "public/vividsites/spring-tooltip.html",
      "content": "<!--\n  vs-spring-tooltip: ONE tooltip shared by a whole group of triggers. Move from trigger to trigger and it\n  glides there on a position spring while its width morphs on a size spring, and the label slides out\n  and in along the direction you travelled. It leans a little with its own velocity, flips top/bottom\n  and shifts sideways to stay on screen, with the arrow still pointing at the anchor.\n\n  Markup\n    <div class=\"vs-spring-tooltip\" data-vs-spring-tooltip> … triggers … </div>\n    Any element inside with data-vs-tip=\"Label\" is a trigger (use a focusable element: button, a).\n      data-vs-tip-meta=\"#2F5D50\"   optional secondary text, set in mono\n      data-vs-tip-dot=\"#2F5D50\"    optional colour chip before the label\n    Group attributes (read live, change them any time):\n      data-vs-spring-tooltip-mode=\"glide|follow\"      follow: tracks the pointer's x inside a trigger\n      data-vs-spring-tooltip-placement=\"top|bottom\"   preferred side; flips when there is no room\n      data-vs-spring-tooltip-theme=\"ink|paper\"\n\n  Behaviour\n    First hover waits --delay; once a tooltip has shown, other triggers open instantly for --skip ms.\n    Keyboard focus (focus-visible) shows it at once, Escape hides it, blur hides it.\n    Touch: long-press shows it; keep the finger down and slide to scrub across triggers.\n    The active trigger gets aria-describedby pointing at the role=\"tooltip\" element.\n    JS: el.vsSpringTooltip.show(trigger) / .hide().  prefers-reduced-motion: no glide, no lean, a plain fade.\n\n  Variables on the group (.vs-spring-tooltip)\n    --vs-spring-tooltip-stiffness   position/size spring stiffness        380\n    --vs-spring-tooltip-delay       first-hover delay, ms                 320\n    --vs-spring-tooltip-skip        instant-reopen window, ms             450\n    --vs-spring-tooltip-gap         distance from the anchor, px          10\n    --vs-spring-tooltip-tilt        lean from velocity, 0 = none          1\n  Variables on the tooltip (.vs-spring-tooltip-tip, it lives in <body>)\n    --vs-spring-tooltip-bg / -ink / -dim / -ring    per theme below\n    --vs-spring-tooltip-h           height                                34px\n    --vs-spring-tooltip-pad         side padding                          12px\n    --vs-spring-tooltip-font        label size                            13.5px\n  Fonts: Mulish 500 + DM Mono 400 (Google Fonts), falls back to system faces.\n-->\n<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=DM+Mono:wght@400&family=Mulish:wght@500&display=swap\">\n<style>\n  .vs-spring-tooltip {\n    --vs-spring-tooltip-stiffness: 380;\n    --vs-spring-tooltip-delay: 320;\n    --vs-spring-tooltip-skip: 450;\n    --vs-spring-tooltip-gap: 10;\n    --vs-spring-tooltip-tilt: 1;\n    --vs-spring-tooltip-swatch: 88px;\n    display: flex; flex-wrap: wrap; justify-content: center; gap: calc(var(--vs-spring-tooltip-swatch) * .3);\n  }\n  /* example triggers: colour swatches */\n  .vs-spring-tooltip-swatch {\n    --c: #2f5d50;\n    position: relative; width: var(--vs-spring-tooltip-swatch); height: var(--vs-spring-tooltip-swatch);\n    border: 0; padding: 0; border-radius: 50%; cursor: pointer; flex: none;\n    background:\n      radial-gradient(120% 90% at 30% 18%, rgba(255,255,255,.34), rgba(255,255,255,0) 46%),\n      radial-gradient(90% 90% at 70% 100%, rgba(0,0,0,.2), rgba(0,0,0,0) 60%),\n      var(--c);\n    box-shadow:\n      inset 0 0 0 .5px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.4), inset 0 -10px 18px -8px rgba(0,0,0,.22),\n      0 1px 1px rgba(70,35,15,.1), 0 4px 8px -2px rgba(70,35,15,.12),\n      0 18px 30px -12px color-mix(in srgb, var(--c) 70%, transparent);\n    transition: transform .5s cubic-bezier(.34,1.56,.64,1), box-shadow .3s;\n    -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none;\n    touch-action: manipulation;\n  }\n  .vs-spring-tooltip-swatch::after { /* sub-pixel outer ring that reads on any stage */\n    content: \"\"; position: absolute; inset: -.5px; border-radius: inherit; pointer-events: none;\n    box-shadow: 0 0 0 .5px rgba(60,30,10,.16);\n  }\n  .vs-spring-tooltip-swatch:hover { transform: scale(1.045); }\n  .vs-spring-tooltip-swatch:active { transform: scale(.95); transition-duration: .12s; }\n  .vs-spring-tooltip-swatch:focus-visible { outline: none; }\n  .vs-spring-tooltip-swatch:focus-visible::after { box-shadow: 0 0 0 3px rgba(255,255,255,.9), 0 0 0 5px #1d1a18; }\n  .vs-spring-tooltip-swatch:disabled { cursor: not-allowed; filter: saturate(.2); opacity: .45; transform: none; }\n\n  /* the one shared tooltip (appended to <body>) */\n  .vs-spring-tooltip-tip {\n    --vs-spring-tooltip-h: 34px;\n    --vs-spring-tooltip-pad: 12px;\n    --vs-spring-tooltip-font: 13.5px;\n    --vs-spring-tooltip-bg: #1f1b18;\n    --vs-spring-tooltip-ink: #f7efe8;\n    --vs-spring-tooltip-dim: rgba(247,239,232,.56);\n    --vs-spring-tooltip-ring: rgba(0,0,0,.5);\n    --vs-spring-tooltip-hi: rgba(255,255,255,.14);\n    --vs-spring-tooltip-shadow: 0 1px 2px rgba(40,20,8,.14), 0 6px 14px -4px rgba(40,20,8,.26), 0 18px 34px -12px rgba(40,20,8,.34);\n    position: fixed; left: 0; top: 0; z-index: 1000; pointer-events: none; visibility: hidden; opacity: 0;\n    height: var(--vs-spring-tooltip-h); width: 80px; border-radius: calc(var(--vs-spring-tooltip-h) * .3);\n    color: var(--vs-spring-tooltip-ink); background: var(--vs-spring-tooltip-bg);\n    box-shadow: 0 0 0 .5px var(--vs-spring-tooltip-ring), inset 0 .5px 0 var(--vs-spring-tooltip-hi), var(--vs-spring-tooltip-shadow);\n    font: 500 var(--vs-spring-tooltip-font)/1 \"Mulish\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n    letter-spacing: .005em; white-space: nowrap; -webkit-font-smoothing: antialiased;\n    will-change: transform, opacity; contain: layout style;\n  }\n  .vs-spring-tooltip-tip[data-theme=\"paper\"] {\n    --vs-spring-tooltip-bg: #fffaf6;\n    --vs-spring-tooltip-ink: #2a211c;\n    --vs-spring-tooltip-dim: rgba(42,33,28,.5);\n    --vs-spring-tooltip-ring: rgba(70,35,15,.16);\n    --vs-spring-tooltip-hi: #fff;\n    --vs-spring-tooltip-shadow: 0 1px 2px rgba(70,35,15,.08), 0 6px 14px -4px rgba(70,35,15,.14), 0 18px 34px -12px rgba(70,35,15,.24);\n  }\n  .vs-spring-tooltip-clip { position: absolute; inset: 0; border-radius: inherit; overflow: hidden; }\n  .vs-spring-tooltip-l {\n    position: absolute; left: 50%; top: 0; height: 100%; display: flex; align-items: center; gap: 7px;\n    will-change: transform, opacity;\n  }\n  .vs-spring-tooltip-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--c); box-shadow: inset 0 .5px 0 rgba(255,255,255,.3), 0 0 0 1px rgba(255,255,255,.2); flex: none; }\n  [data-theme=\"paper\"] .vs-spring-tooltip-dot { box-shadow: inset 0 0 0 .5px rgba(0,0,0,.2); }\n  .vs-spring-tooltip-sep { color: var(--vs-spring-tooltip-dim); margin: 0 -1px; }\n  .vs-spring-tooltip-m { font: 400 calc(var(--vs-spring-tooltip-font) * .88)/1 \"DM Mono\", ui-monospace, \"SF Mono\", Menlo, monospace; color: var(--vs-spring-tooltip-dim); letter-spacing: .02em; }\n  .vs-spring-tooltip-arrow { position: absolute; left: 0; top: calc(100% - .5px); width: 16px; height: 8px; fill: var(--vs-spring-tooltip-bg); overflow: visible; }\n  .vs-spring-tooltip-arrow .vs-spring-tooltip-edge { fill: none; stroke: var(--vs-spring-tooltip-ring); stroke-width: .5px; }\n  .vs-spring-tooltip-tip.is-below .vs-spring-tooltip-arrow { top: auto; bottom: calc(100% - .5px); }\n  .vs-spring-tooltip-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n</style>\n\n<div class=\"vs-spring-tooltip\" data-vs-spring-tooltip data-vs-spring-tooltip-mode=\"glide\" data-vs-spring-tooltip-placement=\"top\" data-vs-spring-tooltip-theme=\"ink\" role=\"group\" aria-label=\"Palette\">\n  <button class=\"vs-spring-tooltip-swatch\" style=\"--c:#c4603c\" data-vs-tip=\"Terracotta\" data-vs-tip-meta=\"#C4603C\" data-vs-tip-dot=\"#c4603c\" aria-label=\"Terracotta\"></button>\n  <button class=\"vs-spring-tooltip-swatch\" style=\"--c:#e2a33c\" data-vs-tip=\"Saffron\" data-vs-tip-meta=\"#E2A33C\" data-vs-tip-dot=\"#e2a33c\" aria-label=\"Saffron\"></button>\n  <button class=\"vs-spring-tooltip-swatch\" style=\"--c:#a4b38c\" data-vs-tip=\"Sage\" data-vs-tip-meta=\"#A4B38C\" data-vs-tip-dot=\"#a4b38c\" aria-label=\"Sage\"></button>\n  <button class=\"vs-spring-tooltip-swatch\" style=\"--c:#2f5d50\" data-vs-tip=\"Kelp\" data-vs-tip-meta=\"#2F5D50\" data-vs-tip-dot=\"#2f5d50\" aria-label=\"Kelp\"></button>\n  <button class=\"vs-spring-tooltip-swatch\" style=\"--c:#26272b\" data-vs-tip=\"Ink\" data-vs-tip-meta=\"#26272B\" data-vs-tip-dot=\"#26272b\" aria-label=\"Ink\"></button>\n  <button class=\"vs-spring-tooltip-swatch\" style=\"--c:#2d5877\" data-vs-tip=\"Harbour Blue\" data-vs-tip-meta=\"#2D5877\" data-vs-tip-dot=\"#2d5877\" aria-label=\"Harbour Blue\"></button>\n</div>\n\n<script>\n(() => {\n  if (window.VsSpringTooltip) return;\n  const RM = matchMedia('(prefers-reduced-motion: reduce)');\n  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n  const ARROW = 7, SUB = 1 / 240;\n  let uid = 0;\n\n  // critically-tunable spring, semi-implicit Euler: stiffness k, damping ratio z (unit mass)\n  const spring = (x) => ({ x, v: 0, t: x });\n  const step = (s, h, k, z) => { s.v += (-k * (s.x - s.t) - 2 * z * Math.sqrt(k) * s.v) * h; s.x += s.v * h; };\n  const snap = (s, x) => { s.x = s.t = x; s.v = 0; };\n  const still = (s, e) => Math.abs(s.x - s.t) < e && Math.abs(s.v) < e * 8;\n\n  function init(root) {\n    if (root.vsSpringTooltip) return root.vsSpringTooltip;\n    const tip = document.createElement('div');\n    tip.className = 'vs-spring-tooltip-tip'; tip.id = 'vs-spring-tooltip-' + (++uid); tip.setAttribute('role', 'tooltip');\n    tip.innerHTML = '<div class=\"vs-spring-tooltip-clip\"></div><svg class=\"vs-spring-tooltip-arrow\" viewBox=\"0 0 16 8\" aria-hidden=\"true\"><path d=\"M0 0h16c-2.2 0-3.4.9-4.7 2.5L9.3 5a1.7 1.7 0 0 1-2.6 0L4.7 2.5C3.4.9 2.2 0 0 0z\"/><path class=\"vs-spring-tooltip-edge\" d=\"M0 0c2.2 0 3.4.9 4.7 2.5L6.7 5a1.7 1.7 0 0 0 2.6 0l2-2.5C12.6.9 13.8 0 16 0\"/></svg>';\n    document.body.appendChild(tip);\n    const clip = tip.firstChild, arrow = tip.lastChild;\n\n    const S = { ax: spring(0), ay: spring(0), sh: spring(0), w: spring(80), o: spring(0), rot: spring(0) };\n    let C = {}, active = null, visible = false, side = 'top', labels = [], raf = 0, last = 0;\n    let openT = 0, hideT = 0, pressT = 0, lastHide = -1e9, px = 0, inside = false, pressing = false, suppressed = null, H = 34, pad = 12, tw = 80;\n\n    const cfg = () => {\n      const cs = getComputedStyle(root), n = (k, d) => { const v = parseFloat(cs.getPropertyValue(k)); return isNaN(v) ? d : v; };\n      C = {\n        mode: root.getAttribute('data-vs-spring-tooltip-mode') || 'glide',\n        place: root.getAttribute('data-vs-spring-tooltip-placement') || 'top',\n        theme: root.getAttribute('data-vs-spring-tooltip-theme') || 'ink',\n        k: clamp(n('--vs-spring-tooltip-stiffness', 380), 40, 2000), delay: n('--vs-spring-tooltip-delay', 320),\n        skip: n('--vs-spring-tooltip-skip', 450), gap: n('--vs-spring-tooltip-gap', 10), tilt: n('--vs-spring-tooltip-tilt', 1), m: 8,\n      };\n      tip.dataset.theme = C.theme;\n      const ts = getComputedStyle(tip); H = tip.offsetHeight || 34; pad = parseFloat(ts.getPropertyValue('--vs-spring-tooltip-pad')) || 12;\n    };\n\n    const makeLabel = (el) => {\n      const l = document.createElement('span'); l.className = 'vs-spring-tooltip-l';\n      const d = el.dataset;\n      if (d.vsTipDot) { const i = document.createElement('i'); i.className = 'vs-spring-tooltip-dot'; i.style.setProperty('--c', d.vsTipDot); l.appendChild(i); }\n      const n = document.createElement('span'); n.className = 'vs-spring-tooltip-n'; n.textContent = d.vsTip; l.appendChild(n);\n      if (d.vsTipMeta) {\n        const s = document.createElement('span'); s.className = 'vs-spring-tooltip-sep'; s.textContent = '·'; s.setAttribute('aria-hidden', 'true');\n        const m = document.createElement('span'); m.className = 'vs-spring-tooltip-m'; m.textContent = d.vsTipMeta; l.append(s, m);\n      }\n      clip.appendChild(l);\n      return { el: l, lw: l.offsetWidth, x: spring(0), o: spring(1) };\n    };\n\n    // where the tooltip wants to be for the active trigger\n    const target = () => {\n      const r = active.getBoundingClientRect(), vw = document.documentElement.clientWidth, vh = innerHeight;\n      const cx = C.mode === 'follow' && inside ? clamp(px, r.left + 4, r.right - 4) : r.left + r.width / 2;\n      const need = H + ARROW + C.gap + C.m;\n      let sd = C.place === 'bottom' ? 'bottom' : 'top';\n      if (sd === 'top' && r.top < need && vh - r.bottom >= need) sd = 'bottom';\n      else if (sd === 'bottom' && vh - r.bottom < need && r.top >= need) sd = 'top';\n      const half = Math.min(tw, vw - 2 * C.m) / 2;\n      return { cx, ay: sd === 'top' ? r.top - C.gap : r.bottom + C.gap, sd, sh: clamp(cx, C.m + half, vw - C.m - half) - cx };\n    };\n    const retarget = (hard) => {\n      if (!active) return;\n      const t = target();\n      if (t.sd !== side) { side = t.sd; tip.classList.toggle('is-below', side === 'bottom'); snap(S.ay, t.ay + (side === 'top' ? 6 : -6)); S.o.x = Math.min(S.o.x, .45); }\n      S.ax.t = t.cx; S.ay.t = t.ay; S.sh.t = t.sh; S.w.t = tw;\n      if (hard) { snap(S.ax, t.cx); snap(S.ay, t.ay); snap(S.sh, t.sh); }\n      kick();\n    };\n\n    const show = (el) => {\n      clearTimeout(openT); clearTimeout(hideT);\n      cfg();\n      const warm = S.o.x > .15 && active;\n      const prev = active; active = el; visible = true;\n      if (prev && prev !== el) prev.removeAttribute('aria-describedby');\n      el.setAttribute('aria-describedby', tip.id);\n      tip.style.visibility = 'visible';\n      const L = makeLabel(el); tw = Math.ceil(L.lw + pad * 2);\n      const rm = RM.matches;\n      if (warm && prev !== el && !rm) {\n        const a = prev.getBoundingClientRect(), b = el.getBoundingClientRect();\n        const dx = (b.left + b.width / 2) - (a.left + a.width / 2), dir = Math.abs(dx) < 4 ? 0 : Math.sign(dx);\n        for (const o of labels) { o.x.t = -dir * 16; o.o.t = 0; o.el.setAttribute('aria-hidden', 'true'); }\n        L.x = spring(dir * 16); L.x.t = 0; L.o = spring(0); L.o.t = 1;\n        labels.push(L); retarget(false);\n      } else {\n        for (const o of labels) o.el.remove();\n        labels = [L];\n        const t = target(); side = t.sd; tip.classList.toggle('is-below', side === 'bottom');\n        snap(S.ax, t.cx); snap(S.ay, t.ay); snap(S.sh, t.sh); snap(S.w, tw); snap(S.rot, 0);\n        if (!warm) S.o.v = 0;\n      }\n      S.o.t = 1; kick();\n    };\n    const hide = () => {\n      clearTimeout(openT); clearTimeout(hideT);\n      if (!visible) return;\n      visible = false; S.o.t = 0; lastHide = performance.now();\n      if (active) active.removeAttribute('aria-describedby');\n      kick();\n    };\n    const enter = (el, how) => {\n      clearTimeout(hideT);\n      if (suppressed === el) return; suppressed = null;\n      if (el === active && visible) return;\n      const warm = visible || performance.now() - lastHide < (C.skip || 450);\n      if (how === 'hover' && !warm) { clearTimeout(openT); cfg(); openT = setTimeout(() => show(el), C.delay); return; }\n      show(el);\n    };\n    const leave = (ms) => { clearTimeout(openT); clearTimeout(hideT); hideT = setTimeout(hide, ms); };\n\n    const kick = () => { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n    function tick(now) {\n      const dt = Math.min(.05, Math.max(.001, (now - last) / 1000)); last = now;\n      const rm = RM.matches, k = C.k || 380, n = Math.ceil(dt / SUB), h = dt / n;\n      if (rm) { snap(S.ax, S.ax.t); snap(S.ay, S.ay.t); snap(S.sh, S.sh.t); snap(S.w, S.w.t); snap(S.rot, 0); }\n      for (let i = 0; i < n; i++) {\n        step(S.ax, h, k, .74); step(S.ay, h, k, .8); step(S.sh, h, k, .86); step(S.w, h, k * 1.2, .72);\n        step(S.o, h, rm ? 900 : 520, visible ? .68 : 1);\n        S.rot.t = rm ? 0 : clamp((side === 'top' ? -1 : 1) * S.ax.v * .0026 * C.tilt, -7, 7);\n        step(S.rot, h, 240, .5);\n        for (const l of labels) { const out = l.o.t === 0; step(l.x, h, out ? 700 : 420, .9); step(l.o, h, out ? 1800 : 360, 1); }\n      }\n      // retire faded labels\n      labels = labels.filter((l) => { if (l.o.t === 0 && l.o.x < .02) { l.el.remove(); return false; } return true; });\n      // paint\n      const w = Math.max(24, S.w.x), cx = S.ax.x, L = cx + S.sh.x - w / 2, o = clamp(S.o.x, 0, 1);\n      const r = H * .3, axl = clamp(cx - L, r + 8, w - r - 8);\n      const T = side === 'top' ? S.ay.x - H - ARROW : S.ay.x + ARROW;\n      const tx = axl, ty = side === 'top' ? H + ARROW : -ARROW, lift = rm ? 0 : (1 - o) * 5 * (side === 'top' ? 1 : -1);\n      const sc = rm ? 1 : .88 + .12 * S.o.x;\n      tip.style.width = w.toFixed(2) + 'px';\n      tip.style.opacity = o.toFixed(3);\n      tip.style.transform = `translate3d(${L.toFixed(2)}px,${(T + lift).toFixed(2)}px,0) translate(${tx.toFixed(2)}px,${ty}px) rotate(${S.rot.x.toFixed(3)}deg) scale(${sc.toFixed(4)}) translate(${(-tx).toFixed(2)}px,${-ty}px)`;\n      arrow.style.transform = `translateX(${(axl - 8).toFixed(2)}px)` + (side === 'bottom' ? ' rotate(180deg)' : '');\n      for (const l of labels) { l.el.style.transform = `translate3d(${(l.x.x - l.lw / 2).toFixed(2)}px,0,0)`; l.el.style.opacity = clamp(l.o.x, 0, 1).toFixed(3); }\n      const settled = still(S.ax, .05) && still(S.ay, .05) && still(S.sh, .05) && still(S.w, .05) && still(S.o, .002) && still(S.rot, .01) && labels.every((l) => still(l.x, .05) && still(l.o, .002));\n      if (settled) {\n        raf = 0;\n        if (!visible) { tip.style.visibility = 'hidden'; tip.style.opacity = 0; for (const l of labels) l.el.remove(); labels = []; active = null; }\n        return;\n      }\n      raf = requestAnimationFrame(tick);\n    }\n\n    const trig = (n) => { const t = n && n.closest && n.closest('[data-vs-tip]'); return t && root.contains(t) && !t.disabled ? t : null; };\n    const on = (t, ev, fn, o) => t.addEventListener(ev, fn, o);\n    const ac = new AbortController(), sig = { signal: ac.signal };\n    on(root, 'pointerover', (e) => { if (e.pointerType === 'touch') return; const t = trig(e.target); if (t) { inside = true; px = e.clientX; enter(t, 'hover'); } }, sig);\n    on(root, 'pointerout', (e) => {\n      if (e.pointerType === 'touch') return;\n      const from = trig(e.target), to = trig(e.relatedTarget);\n      if (from && from !== to) { inside = false; if (!to) leave(e.relatedTarget && root.contains(e.relatedTarget) ? 300 : 110); }\n    }, sig);\n    on(root, 'pointermove', (e) => { if (e.pointerType === 'touch') return; px = e.clientX; if (C.mode === 'follow' && visible && active) retarget(false); }, sig);\n    on(root, 'focusin', (e) => { const t = trig(e.target); if (t && t.matches(':focus-visible')) { suppressed = null; show(t); } }, sig);\n    on(root, 'focusout', (e) => { if (trig(e.target) && !trig(e.relatedTarget)) leave(0); }, sig);\n    on(document, 'keydown', (e) => { if (e.key === 'Escape' && visible) { suppressed = active; hide(); } }, sig);\n    // touch: long-press to show, slide to scrub, lift to dismiss\n    let sx = 0, sy = 0;\n    on(root, 'touchstart', (e) => {\n      const t = trig(e.target); if (!t) return;\n      const p = e.touches[0]; sx = p.clientX; sy = p.clientY; clearTimeout(pressT);\n      pressT = setTimeout(() => { pressing = true; suppressed = null; inside = true; px = sx; show(t); if (navigator.vibrate) try { navigator.vibrate(8); } catch (_) {} }, 420);\n    }, { passive: true, signal: ac.signal });\n    on(root, 'touchmove', (e) => {\n      const p = e.touches[0];\n      if (!pressing) { if (Math.hypot(p.clientX - sx, p.clientY - sy) > 10) clearTimeout(pressT); return; }\n      e.preventDefault(); px = p.clientX;\n      const t = trig(document.elementFromPoint(p.clientX, p.clientY));\n      if (t && t !== active) show(t); else if (t && C.mode === 'follow') retarget(false);\n    }, { passive: false, signal: ac.signal });\n    const endTouch = (e) => { clearTimeout(pressT); if (pressing) { pressing = false; inside = false; if (e.cancelable) e.preventDefault(); leave(900); } };\n    on(root, 'touchend', endTouch, sig); on(root, 'touchcancel', endTouch, sig);\n    on(root, 'contextmenu', (e) => { if (pressing || trig(e.target)) e.preventDefault(); }, sig);\n    on(window, 'scroll', () => { if (visible) retarget(true); }, { passive: true, capture: true, signal: ac.signal });\n    on(window, 'resize', () => { if (visible) { cfg(); retarget(true); } }, sig);\n    const mo = new MutationObserver(() => { cfg(); if (visible) retarget(false); });\n    mo.observe(root, { attributes: true, attributeFilter: ['style', 'class', 'data-vs-spring-tooltip-mode', 'data-vs-spring-tooltip-placement', 'data-vs-spring-tooltip-theme'] });\n    if (document.fonts) document.fonts.ready.then(() => { if (visible && active) { const l = labels[labels.length - 1]; if (l) { l.lw = l.el.offsetWidth; tw = Math.ceil(l.lw + pad * 2); retarget(false); } } });\n    cfg();\n\n    const api = {\n      show: (el) => { suppressed = null; show(el); }, hide,\n      destroy: () => { ac.abort(); mo.disconnect(); cancelAnimationFrame(raf); tip.remove(); if (active) active.removeAttribute('aria-describedby'); delete root.vsSpringTooltip; },\n      get tip() { return tip; },\n    };\n    root.vsSpringTooltip = api;\n    return api;\n  }\n\n  window.VsSpringTooltip = { init };\n  const boot = () => document.querySelectorAll('[data-vs-spring-tooltip]').forEach(init);\n  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Loaders, cursors & feedback",
    "tags": [
      "tooltip",
      "spring",
      "hover",
      "popover",
      "swatch",
      "accessibility",
      "touch"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=DM+Mono:wght@400&family=Mulish:wght@500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#c4603c",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/spring-tooltip/index.html",
    "thumbnail": "https://vividsites.app/api/component/spring-tooltip/thumb.jpg",
    "video": "https://vividsites.app/api/component/spring-tooltip/thumb.mp4",
    "page": "https://vividsites.app/c/spring-tooltip"
  }
}