{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spring-slider",
  "type": "registry:block",
  "title": "Spring Slider",
  "description": "A range slider on real springs: drag past an end and the track stretches with rubber-band resistance and springs back, while the value bubble tilts with the thumb's velocity. Tick snapping, a two-thumb range, full keyboard and ARIA slider roles.",
  "docs": "Spring Slider was added to public/vividsites/spring-slider.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-slider",
  "categories": [
    "ui",
    "buttons-inputs"
  ],
  "files": [
    {
      "path": "components/vividsites/spring-slider.html",
      "type": "registry:file",
      "target": "public/vividsites/spring-slider.html",
      "content": "<!--\nvs-spring-slider: a range slider on real springs. Drag past either end and the track\n  stretches with asymptotic (rubber-band) resistance, then springs back. The value bubble\n  hangs off the thumb and tilts with its velocity; snapping pulls the thumb to the next tick\n  on a spring and kicks that tick. Thumbs squash-and-stretch along their motion.\n  Keyboard: arrows (one step), PageUp/PageDown (big step), Home/End. Pushing past an end\n  gives a small bump. Touch and pen work; the page still scrolls vertically.\n\n  Markup:  <div class=\"vs-spring-slider\" aria-label=\"Volume\" data-value=\"40\"></div>\n  Attributes:\n    aria-label     names the slider (each thumb gets role=\"slider\" + aria-value*)\n    data-min / data-max / data-step   range and step (defaults 0 / 100 / 1)\n    data-value     start value; \"a,b\" for the range variant\n    data-range     two thumbs with a fill between them (data-labels=\"Min name,Max name\")\n    data-snap      snap to multiples of this value, with ticks + labels (0 = off)\n    data-bounce    0 (critically damped) – 1 (very springy), default .5\n    data-bubble    \"auto\" (hover/focus/drag, default), \"always\" or \"off\"\n    data-prefix / data-suffix   around the value, e.g. \"$\" or \"%\"\n    data-ticks     \"off\" hides the tick ruler\n    data-name      adds a hidden <input> with this name for forms\n    aria-disabled  \"true\" dims and blocks input\n  Events: \"input\" while it moves, \"change\" on release/key, both with detail.value.\n  JS: el.__vsSs.value = 60 (or [a, b]); window.vsSpringSlider.mount(el) for late nodes.\n  Size everything with --vs-spring-slider-size. Reduced motion: no springs, no tilt.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Mono&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-spring-slider\" aria-label=\"Volume\" data-min=\"0\" data-max=\"100\" data-value=\"40\" data-snap=\"10\" data-suffix=\"%\"></div>\n<div class=\"vs-spring-slider\" data-range aria-label=\"Price\" data-labels=\"Minimum price,Maximum price\" data-min=\"0\" data-max=\"500\" data-step=\"10\" data-value=\"120,360\" data-prefix=\"$\"></div>\n\n<style>\n.vs-spring-slider {\n  --vs-spring-slider-size: 1rem;                 /* scales everything (thumb = 1.5em) */\n  --vs-spring-slider-accent: #2d4bff;            /* fill, active ticks, focus ring */\n  --vs-spring-slider-track: rgba(28,24,20,.1);   /* the empty track */\n  --vs-spring-slider-thumb: #ffffff;\n  --vs-spring-slider-ink: #1c1814;               /* tick labels, bubble fill */\n  --vs-spring-slider-bubble-ink: #f7f2ea;        /* text inside the bubble */\n  --vs-spring-slider-font: \"Space Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n\n  position: relative; display: block; width: 100%; box-sizing: border-box;\n  font-size: var(--vs-spring-slider-size); font-family: var(--vs-spring-slider-font);\n  padding: 3.4em .25em 0; color: var(--vs-spring-slider-ink);\n  -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none;\n}\n.vs-spring-slider[data-bubble=\"off\"] { padding-top: .25em; }\n.vs-spring-slider.has-ticks { padding-bottom: 2.1em; }\n.vs-spring-slider *, .vs-spring-slider *::before, .vs-spring-slider *::after { box-sizing: border-box; }\n\n.vs-spring-slider-body { position: relative; height: 2.5em; cursor: pointer; touch-action: pan-y; }\n.vs-spring-slider-rail {\n  position: absolute; left: 0; right: 0; top: 50%; height: .92em; margin-top: -.46em; border-radius: 99em;\n  overflow: hidden; background: var(--vs-spring-slider-track); transform-origin: 0 50%; will-change: transform;\n  box-shadow: inset 0 0 0 .5px rgba(0,0,0,.05), inset 0 1px 2px rgba(0,0,0,.07);\n}\n.vs-spring-slider-fill {\n  position: absolute; inset: 0; transform-origin: 0 50%; will-change: transform;\n  background: linear-gradient(180deg, color-mix(in srgb, var(--vs-spring-slider-accent) 82%, #fff), var(--vs-spring-slider-accent) 55%);\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.28), inset 0 -1px 0 rgba(0,0,0,.08);\n}\n\n.vs-spring-slider-thumb {\n  position: absolute; left: 0; top: 50%; width: 1.5em; height: 1.5em; margin: -.75em 0 0 -.75em; border-radius: 50%;\n  outline: none; cursor: grab; will-change: transform; z-index: 2;\n}\n.vs-spring-slider-thumb::before { /* the face, above the halo */\n  content: \"\"; position: absolute; inset: 0; z-index: 1; border-radius: 50%; background: var(--vs-spring-slider-thumb);\n  box-shadow: 0 0 0 .5px rgba(0,0,0,.07), 0 .06em .12em rgba(20,14,8,.12), 0 .25em .7em -.1em rgba(20,14,8,.26),\n              inset 0 -.08em .1em rgba(0,0,0,.05), inset 0 .06em 0 rgba(255,255,255,.9);\n  transition: box-shadow .15s cubic-bezier(.2,0,0,1);\n}\n.vs-spring-slider-thumb::after { /* the inner dot */\n  content: \"\"; position: absolute; z-index: 2; left: 50%; top: 50%; width: .3em; height: .3em; margin: -.15em; border-radius: 50%;\n  background: var(--vs-spring-slider-accent);\n}\n.vs-spring-slider.is-dragging .vs-spring-slider-thumb { cursor: grabbing; }\n.vs-spring-slider-halo {\n  position: absolute; inset: -.62em; border-radius: 50%; pointer-events: none;\n  background: color-mix(in srgb, var(--vs-spring-slider-accent) 16%, transparent); transform: scale(0); will-change: transform;\n}\n.vs-spring-slider-thumb:focus-visible::before { box-shadow: 0 0 0 .5px rgba(0,0,0,.07), 0 .25em .7em -.1em rgba(20,14,8,.26), 0 0 0 .16em var(--vs-spring-slider-thumb), 0 0 0 .3em var(--vs-spring-slider-accent); }\n\n.vs-spring-slider-bubble {\n  position: absolute; left: 0; bottom: calc(50% + 1.15em); z-index: 3; pointer-events: none;\n  min-width: 3.1em; height: 1.95em; padding: 0 .62em; border-radius: .62em;\n  display: grid; place-items: center; transform-origin: 50% 100%; will-change: transform, opacity;\n  background: var(--vs-spring-slider-ink); color: var(--vs-spring-slider-bubble-ink);\n  font-size: .86em; line-height: 1; letter-spacing: -.01em; font-variant-numeric: tabular-nums; white-space: nowrap;\n  box-shadow: 0 0 0 .5px rgba(255,255,255,.06) inset, 0 1px 0 rgba(255,255,255,.12) inset, 0 .5em 1.2em -.4em rgba(20,14,8,.45), 0 .12em .3em rgba(20,14,8,.18);\n  opacity: 0;\n}\n.vs-spring-slider-bubble::after { /* tail */\n  content: \"\"; position: absolute; left: 50%; bottom: -.3em; width: .62em; height: .62em; margin-left: -.31em;\n  background: inherit; border-radius: 0 0 .14em 0; transform: rotate(45deg); z-index: -1;\n}\n\n.vs-spring-slider-ticks { position: absolute; left: 0; right: 0; top: calc(50% + .78em); height: 1.8em; pointer-events: none; }\n.vs-spring-slider-ticks i {\n  position: absolute; top: 0; width: 1px; height: .38em; margin-left: -.5px; background: currentColor; opacity: .22;\n  transform-origin: 50% 0; transition: opacity .2s cubic-bezier(.2,0,0,1), background-color .2s cubic-bezier(.2,0,0,1);\n}\n.vs-spring-slider-ticks i.is-major { height: .6em; }\n.vs-spring-slider-ticks i.is-on { background: var(--vs-spring-slider-accent); opacity: 1; }\n.vs-spring-slider-ticks b {\n  position: absolute; top: .95em; font-weight: 400; font-size: .56em; letter-spacing: .02em; transform: translateX(-50%);\n  opacity: .38; font-variant-numeric: tabular-nums; white-space: nowrap; transition: opacity .2s cubic-bezier(.2,0,0,1);\n}\n.vs-spring-slider-ticks b.is-on { opacity: .9; }\n\n.vs-spring-slider[aria-disabled=\"true\"] { opacity: .45; }\n.vs-spring-slider[aria-disabled=\"true\"] .vs-spring-slider-body, .vs-spring-slider[aria-disabled=\"true\"] .vs-spring-slider-thumb { cursor: not-allowed; }\n@media (forced-colors: active) {\n  .vs-spring-slider-rail { border: 1px solid CanvasText; } .vs-spring-slider-fill { background: Highlight; }\n  .vs-spring-slider-thumb { border: 2px solid CanvasText; }\n}\n</style>\n\n<script>\n(() => {\n  if (window.vsSpringSlider) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  /* one shared ticker: it only runs while some spring is still moving */\n  const live = new Set(); let raf = 0, last = 0;\n  const tick = now => {\n    const dt = Math.min(.064, (now - last) / 1000 || .016); last = now;\n    for (const s of live) if (!s.update(dt)) live.delete(s);\n    raf = live.size ? requestAnimationFrame(tick) : 0;\n  };\n  const wake = s => { live.add(s); if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n  /* semi-implicit Euler spring: k = stiffness, z = damping ratio (1 = critical) */\n  const sp = (x, k, z, eps) => ({ x, v: 0, g: x, k, z, eps });\n  const step = (s, h) => { s.v += (-s.k * (s.x - s.g) - 2 * Math.sqrt(s.k) * s.z * s.v) * h; s.x += s.v * h; };\n  const clamp = (x, a, b) => Math.min(b, Math.max(a, x));\n  const num = (v, d) => { const n = parseFloat(v); return Number.isFinite(n) ? n : d; };\n\n  function mount(el) {\n    if (el.__vsSs) return el.__vsSs;\n    const range = el.hasAttribute('data-range');\n    const min = num(el.dataset.min, 0), max = num(el.dataset.max, 100), span = max - min || 1;\n    const step0 = num(el.dataset.step, 1);\n    const pre = el.dataset.prefix || '', suf = el.dataset.suffix || '';\n    const dec = Math.max(0, ((String(step0).split('.')[1]) || '').length);\n    const fmt = v => pre + v.toFixed(dec) + suf;\n    const names = (el.dataset.labels || '').split(',');\n    const label = el.getAttribute('aria-label') || 'Value';\n    el.removeAttribute('aria-label');\n    el.setAttribute('role', 'group'); el.setAttribute('aria-label', label);\n\n    el.innerHTML = '<div class=\"vs-spring-slider-body\"><div class=\"vs-spring-slider-rail\"><div class=\"vs-spring-slider-fill\"></div></div>' +\n      '<div class=\"vs-spring-slider-ticks\" aria-hidden=\"true\"></div></div>';\n    const body = el.firstChild, rail = body.firstChild, fill = rail.firstChild, ticksEl = body.lastChild;\n    let input = null;\n    if (el.dataset.name) { input = document.createElement('input'); input.type = 'hidden'; input.name = el.dataset.name; el.append(input); }\n\n    const vals0 = (el.dataset.value || (range ? `${min + span * .25},${min + span * .75}` : `${min + span / 2}`)).split(',').map(v => clamp(num(v, min), min, max));\n    const T = vals0.slice(0, range ? 2 : 1).map((v, i) => {\n      const t = document.createElement('div');\n      t.className = 'vs-spring-slider-thumb'; t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-orientation', 'horizontal');\n      t.setAttribute('aria-label', range ? (names[i] || (i ? 'Maximum ' : 'Minimum ') + label.toLowerCase()) : label);\n      t.innerHTML = '<span class=\"vs-spring-slider-halo\"></span>';\n      const b = document.createElement('div'); b.className = 'vs-spring-slider-bubble'; b.setAttribute('aria-hidden', 'true'); b.innerHTML = '<span></span>';\n      body.append(t, b);\n      const n = (v - min) / span;\n      return { i, el: t, halo: t.firstChild, bub: b, txt: b.firstChild, value: v,\n        p: sp(n, 900, .9, .00015), tilt: sp(0, 240, .38, .02), show: sp(0, 420, .62, .002), press: sp(0, 520, .6, .002), hot: sp(0, 380, .8, .002) };\n    });\n    const O = sp(0, 900, 1, .04), H = sp(0, 360, .8, .002);\n    const springs = () => [O, H, ...T.flatMap(t => [t.p, t.tilt, t.show, t.press, t.hot])];\n\n    const snapOf = () => Math.max(0, num(el.dataset.snap, 0));\n    const zeta = () => 1 - clamp(num(el.dataset.bounce, .5), 0, 1) * .72;\n    const disabled = () => el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('data-disabled');\n    const always = () => el.dataset.bubble === 'always';\n    let seen = el.dataset.value, G = { W: 1, r: 12, L: 30, em: 16 }, hover = false, near = null, focusT = null, drag = null, marks = [];\n\n    const geo = () => {\n      const em = parseFloat(getComputedStyle(el).fontSize) || 16, W = rail.offsetWidth || 1;\n      G = { W, em, r: .75 * em, Tr: Math.max(1, W - 1.5 * em), L: clamp(W * .09, 20, 84) };\n    };\n    const quant = v => { const s = snapOf() || step0; return clamp(min + Math.round((v - min) / s) * s, min, max); };\n\n    function buildTicks() {\n      const s = snapOf(), count = s ? Math.max(1, Math.round(span / s)) : 1;\n      const maxLabels = Math.max(2, Math.floor(G.W / (3.4 * G.em)) + 1);\n      let every = count;\n      for (const c of [1, 2, 4, 5, 10, 20, 25, 50, 100]) if (count % c === 0 && count / c + 1 <= maxLabels) { every = c; break; }\n      let h = '';\n      marks = [];\n      for (let k = 0; k <= count; k++) {\n        const f = k / count, v = min + f * span, major = k % every === 0;\n        const left = `calc(${G.r}px + ${f} * (100% - ${2 * G.r}px))`;\n        h += `<i class=\"${major ? 'is-major' : ''}\" style=\"left:${left}\"></i>`;\n        if (major) h += `<b style=\"left:${left}\">${fmt(v).replace(/\\.0+(?=\\D*$)/, '')}</b>`;\n        marks.push(v);\n      }\n      if (el.dataset.ticks === 'off') h = '';\n      ticksEl.innerHTML = h;\n      el.classList.toggle('has-ticks', !!h);\n      paintTicks();\n    }\n    function paintTicks() {\n      const lo = range ? T[0].value : min, hi = range ? T[1].value : T[0].value;\n      const is = ticksEl.children; let k = 0;\n      for (const c of is) {\n        const v = marks[c.tagName === 'I' ? k++ : k - 1];\n        c.classList.toggle('is-on', v >= lo - 1e-9 && v <= hi + 1e-9);\n      }\n    }\n    function pulse(v) {\n      if (reduce.matches || !snapOf()) return;\n      const k = marks.findIndex(m => Math.abs(m - v) < 1e-9); if (k < 0) return;\n      const i = ticksEl.querySelectorAll('i')[k];\n      i && i.animate && i.animate([{ transform: 'scaleY(2.4)' }, { transform: 'scaleY(1)' }], { duration: 460, easing: 'cubic-bezier(.2,1.7,.4,1)' });\n      if (drag && drag.touch && navigator.vibrate) try { navigator.vibrate(4); } catch (_) {}\n    }\n    function aria(t) {\n      const lo = range && t.i === 1 ? T[0].value : min, hi = range && t.i === 0 ? T[1].value : max;\n      t.el.setAttribute('aria-valuemin', lo); t.el.setAttribute('aria-valuemax', hi);\n      t.el.setAttribute('aria-valuenow', t.value); t.el.setAttribute('aria-valuetext', fmt(t.value));\n      t.el.setAttribute('aria-disabled', String(disabled())); t.el.tabIndex = disabled() ? -1 : 0;\n      t.txt.textContent = fmt(t.value);\n    }\n    const emit = type => {\n      const value = range ? T.map(t => t.value) : T[0].value;\n      if (input) input.value = String(value);\n      el.dataset.value = String(value); seen = el.dataset.value;\n      el.dispatchEvent(new CustomEvent(type, { bubbles: true, detail: { value } }));\n    };\n    /* put thumb t at normalised n; visual follows the raw n unless snapping */\n    function setNorm(t, n, { smooth = false, user = true } = {}) {\n      const lo = range && t.i === 1 ? (T[0].value - min) / span : 0, hi = range && t.i === 0 ? (T[1].value - min) / span : 1;\n      n = clamp(n, lo, hi);\n      const v = quant(min + n * span), changed = v !== t.value;\n      t.value = v;\n      t.p.g = snapOf() || !smooth ? (v - min) / span : n;\n      if (changed) { T.forEach(aria); paintTicks(); pulse(v); if (user) emit('input'); }\n      wake(api);\n      return changed;\n    }\n\n    const api = {\n      get value() { return range ? T.map(t => t.value) : T[0].value; },\n      set value(v) { const a = Array.isArray(v) ? v : [v]; T.forEach((t, i) => a[i] != null && (t.p.k = 420, t.p.z = zeta(), setNorm(t, (num(a[i], t.value) - min) / span, { user: false }))); },\n      update(dt) {\n        const n = Math.max(1, Math.round(dt * 240)), h = dt / n;\n        let moving = false;\n        const W = G.W, Tr = G.Tr;\n        for (const t of T) {\n          t.show.g = always() || drag?.t === t || focusT === t || (hover && (!range || near === t)) ? 1 : 0;\n          t.press.g = drag?.t === t ? 1 : 0;\n          t.hot.g = drag?.t === t ? 1 : (focusT === t || (hover && near === t)) ? .84 : 0;\n          t.tilt.g = clamp(-t.p.v * Tr * .018, -24, 24);\n        }\n        H.g = hover || drag || focusT ? 1 : 0;\n        for (const s of springs()) {\n          if (Math.abs(s.v) < s.eps && Math.abs(s.x - s.g) < s.eps) { s.x = s.g; s.v = 0; continue; }\n          moving = true;\n          if (reduce.matches) { s.x = s.g; s.v = 0; continue; }\n          for (let i = 0; i < n; i++) step(s, h);\n        }\n        if (reduce.matches) T.forEach(t => (t.tilt.x = 0));\n        // the stretch: the far end of the rail is anchored, the near end gives with asymptotic resistance\n        const o = O.x, sx = (W + Math.abs(o)) / W, tx = o < 0 ? o : 0;\n        const thin = 1 - .3 * Math.min(1, Math.abs(o) / G.L);\n        rail.style.transform = `translate3d(${tx.toFixed(2)}px,0,0) scale(${sx.toFixed(4)},${((.64 + .36 * H.x) * thin).toFixed(4)})`;\n        const xs = T.map(t => G.r + clamp(t.p.x, -.03, 1.03) * Tr);\n        if (range) fill.style.transform = `translate3d(${xs[0].toFixed(2)}px,0,0) scaleX(${Math.max(0, (xs[1] - xs[0]) / W).toFixed(4)})`;\n        else fill.style.transform = `scaleX(${(xs[0] / W).toFixed(4)})`;\n        T.forEach((t, i) => {\n          const x = tx + xs[i] * sx, vpx = t.p.v * Tr;\n          const st = reduce.matches ? 0 : Math.min(.16, Math.abs(vpx) * .00012);\n          const sc = 1 + .12 * t.press.x;\n          t.el.style.transform = `translate3d(${x.toFixed(2)}px,0,0) scale(${(sc * (1 + st)).toFixed(4)},${(sc * (1 - st * .6)).toFixed(4)})`;\n          t.halo.style.transform = `scale(${Math.max(0, t.hot.x).toFixed(4)})`;\n          const s = clamp(t.show.x, 0, 1.2);\n          t.bub.style.opacity = clamp(t.show.x * 1.4, 0, 1).toFixed(3);\n          t.bub.style.transform = `translate3d(${x.toFixed(2)}px,${((1 - s) * .5 * G.em).toFixed(2)}px,0) translateX(-50%) rotate(${t.tilt.x.toFixed(2)}deg) scale(${(.55 + .45 * s + .05 * t.press.x).toFixed(4)})`;\n        });\n        return moving || !!drag;\n      },\n      mount: () => api,\n    };\n\n    /* ---------- pointer ---------- */\n    const railX = e => e.clientX - body.getBoundingClientRect().left;\n    const nearest = px => {\n      if (!range) return T[0];\n      const d0 = Math.abs(px - (G.r + T[0].p.x * G.Tr)), d1 = Math.abs(px - (G.r + T[1].p.x * G.Tr));\n      if (Math.abs(d0 - d1) < .5) return px < G.r + T[0].p.x * G.Tr ? T[0] : T[1];\n      return d0 < d1 ? T[0] : T[1];\n    };\n    function follow(e) {\n      const t = drag.t, raw = (railX(e) - drag.off - G.r) / G.Tr;\n      // rubber band only past the outer ends (never against the other thumb)\n      let over = 0;\n      if (raw < 0 && (!range || t.i === 0)) over = -raw * G.Tr;\n      if (raw > 1 && (!range || t.i === 1)) over = -(raw - 1) * G.Tr;\n      const d = Math.abs(over);\n      O.g = -Math.sign(over) * G.L * (1 - Math.exp(-d / G.L));\n      O.k = 1400; O.z = 1;\n      const snapping = !!snapOf();\n      t.p.k = snapping ? 520 : drag.moved ? 1500 : 480; t.p.z = snapping ? zeta() : drag.moved ? .95 : .78;\n      setNorm(t, raw, { smooth: true });\n    }\n    body.addEventListener('pointerdown', e => {\n      if (disabled() || e.button > 0) return;\n      geo();\n      const px = railX(e), onThumb = T.find(t => t.el.contains(e.target));\n      const t = onThumb || nearest(px);\n      drag = { id: e.pointerId, t, off: onThumb ? px - (G.r + t.p.x * G.Tr) : 0, x0: e.clientX, moved: false, touch: e.pointerType !== 'mouse' };\n      near = t; el.classList.add('is-dragging');\n      try { body.setPointerCapture(e.pointerId); } catch (_) {}\n      if (!onThumb) follow(e);\n      requestAnimationFrame(() => t.el.focus({ preventScroll: true }));\n      wake(api);\n    });\n    body.addEventListener('pointermove', e => {\n      if (!drag) { if (hover) { const n = nearest(railX(e)); if (n !== near) { near = n; wake(api); } } return; }\n      if (e.pointerId !== drag.id) return;\n      if (!drag.moved && Math.abs(e.clientX - drag.x0) > 2) drag.moved = true;\n      follow(e);\n    });\n    const end = e => {\n      if (!drag || e.pointerId !== drag.id) return;\n      O.g = 0; O.k = 380; O.z = zeta();\n      drag = null; el.classList.remove('is-dragging');\n      emit('change'); wake(api);\n    };\n    body.addEventListener('pointerup', end); body.addEventListener('pointercancel', end);\n    body.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') { hover = true; geo(); near = nearest(railX(e)); wake(api); } });\n    body.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse') { hover = false; wake(api); } });\n\n    /* ---------- keyboard ---------- */\n    T.forEach(t => {\n      t.el.addEventListener('focus', () => { focusT = t.el.matches(':focus-visible') || drag ? t : null; wake(api); });\n      t.el.addEventListener('blur', () => { if (focusT === t) focusT = null; wake(api); });\n      t.el.addEventListener('keydown', e => {\n        if (disabled()) return;\n        const s = snapOf() || step0, big = snapOf() ? s * 2 : Math.max(step0, span / 10);\n        const dv = { ArrowRight: s, ArrowUp: s, ArrowLeft: -s, ArrowDown: -s, PageUp: big, PageDown: -big }[e.key];\n        let target = null;\n        if (dv != null) target = t.value + dv;\n        else if (e.key === 'Home') target = min;\n        else if (e.key === 'End') target = max;\n        else return;\n        e.preventDefault(); focusT = t; geo();\n        t.p.k = 420; t.p.z = zeta();\n        const was = t.value;\n        setNorm(t, (target - min) / span);\n        if (t.value !== was) emit('change');\n        else if ((target < min && (!range || t.i === 0)) || (target > max && (!range || t.i === 1))) {\n          // pushing past an end: a small rubber-band bump\n          O.g = 0; O.k = 380; O.z = zeta(); O.v += (target < min ? -1 : 1) * G.L * 9;\n        }\n        wake(api);\n      });\n    });\n\n    /* ---------- external changes ---------- */\n    new MutationObserver(ms => {\n      for (const m of ms) {\n        if (m.attributeName === 'data-snap') {\n          buildTicks();\n          T.forEach(t => { t.p.k = 420; t.p.z = zeta(); setNorm(t, (t.value - min) / span, { user: false }); });\n        } else if (m.attributeName === 'data-value' && el.dataset.value !== seen) {\n          seen = el.dataset.value; api.value = el.dataset.value.split(',');\n        } else T.forEach(aria);\n      }\n      wake(api);\n    }).observe(el, { attributes: true, attributeFilter: ['data-snap', 'data-value', 'data-disabled', 'aria-disabled', 'data-bubble', 'data-bounce'] });\n    let lastW = 0;\n    new ResizeObserver(() => { geo(); if (G.W !== lastW) { lastW = G.W; buildTicks(); } wake(api); }).observe(el);\n\n    geo(); buildTicks();\n    T.forEach(t => { t.value = quant(t.value); t.p.x = t.p.g = (t.value - min) / span; aria(t); });\n    paintTicks(); if (input) input.value = String(api.value);\n    el.__vsSs = api; api.update(0);\n    return api;\n  }\n  const init = () => document.querySelectorAll('.vs-spring-slider').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsSpringSlider = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Buttons & inputs",
    "tags": [
      "slider",
      "range",
      "input",
      "spring",
      "rubber band",
      "elastic",
      "form",
      "accessibility"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#2d4bff",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/spring-slider/index.html",
    "thumbnail": "https://vividsites.app/api/component/spring-slider/thumb.jpg",
    "video": "https://vividsites.app/api/component/spring-slider/thumb.mp4",
    "page": "https://vividsites.app/c/spring-slider"
  }
}