{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spring-toggle",
  "type": "registry:block",
  "title": "Spring Toggle",
  "description": "A switch whose thumb rides a real spring: it stretches along its velocity, squashes as it lands, widens under your finger and bumps the track on arrival. Drag it with rubber-band ends or flick it; release velocity decides the side.",
  "docs": "Spring Toggle was added to public/vividsites/spring-toggle.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-toggle",
  "categories": [
    "ui",
    "buttons-inputs"
  ],
  "files": [
    {
      "path": "components/vividsites/spring-toggle.html",
      "type": "registry:file",
      "target": "public/vividsites/spring-toggle.html",
      "content": "<!--\nvs-spring-toggle: a switch whose thumb travels on a real spring. It stretches along its\n  velocity, squashes as it lands, widens under your finger (press), and the track gives a\n  small bump when the thumb arrives. Drag it (with rubber-band overscroll at the ends)\n  or flick it: release velocity decides the side. Vibrates 10 ms on touch devices.\n\n  Markup:  <button class=\"vs-spring-toggle\" role=\"switch\" aria-checked=\"false\" aria-label=\"Wi-Fi\"></button>\n  Attributes:\n    aria-checked   \"true\" / \"false\": the state (the script keeps it in sync)\n    aria-busy      \"true\" shows a spinner in the thumb and blocks input (async saves)\n    disabled       dims and blocks input\n    data-name      if set, a hidden <input> with this name carries \"on\"/\"\" for forms\n    data-bounce    0 (critically damped) – 1 (very springy), default .55\n  Modifier: .vs-spring-toggle--icons puts a check / cross glyph in the thumb.\n  Events: \"change\" bubbles from the button with detail.checked.\n  Size everything with --vs-spring-toggle-size. Reduced motion: no springs, instant.\n-->\n<button class=\"vs-spring-toggle\" type=\"button\" role=\"switch\" aria-checked=\"false\" aria-label=\"Airplane mode\"></button>\n\n<style>\n.vs-spring-toggle {\n  --vs-spring-toggle-size: 1rem;              /* scales the whole switch */\n  --vs-spring-toggle-on: #2fcf6b;             /* track when on */\n  --vs-spring-toggle-off: rgba(23,23,26,.14); /* track when off */\n  --vs-spring-toggle-thumb: #ffffff;          /* the thumb */\n  --vs-spring-toggle-glyph: #17171a;          /* icons inside the thumb (--icons) */\n  --vs-spring-toggle-focus: #2f6bff;\n\n  --p: 0;                                     /* 0 → 1, written by the script */\n  position: relative; display: inline-block; vertical-align: middle; flex: none;\n  width: 3.25em; height: 2em; padding: 0; border: 0; border-radius: 999px; cursor: pointer;\n  font-size: var(--vs-spring-toggle-size); outline: none; touch-action: pan-y;\n  -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none;\n  background: color-mix(in srgb, var(--vs-spring-toggle-on) calc(var(--p) * 100%), var(--vs-spring-toggle-off));\n  box-shadow: inset 0 0 0 .5px rgba(0,0,0,.06), inset 0 1px 2px rgba(0,0,0,.08);\n  will-change: transform;\n}\n.vs-spring-toggle, .vs-spring-toggle * { box-sizing: border-box; }\n.vs-spring-toggle-thumb {\n  position: absolute; left: .1875em; top: .1875em; width: 1.625em; height: 1.625em; border-radius: 999px;\n  background: var(--vs-spring-toggle-thumb); pointer-events: none; will-change: transform, width;\n  box-shadow: 0 0 0 .5px rgba(0,0,0,.04), 0 .1875em .5em rgba(0,0,0,.15), 0 .1875em .0625em rgba(0,0,0,.06),\n              inset 0 -.0625em .0625em rgba(0,0,0,.04);\n}\n.vs-spring-toggle-glyph { position: absolute; inset: 0; display: grid; place-items: center; color: var(--vs-spring-toggle-glyph); }\n.vs-spring-toggle-glyph svg { width: .8em; height: .8em; grid-area: 1 / 1; overflow: visible; }\n.vs-spring-toggle-glyph .on { color: color-mix(in srgb, var(--vs-spring-toggle-on) 80%, #000); opacity: var(--p); transform: scale(calc(.4 + var(--p) * .6)) rotate(calc((1 - var(--p)) * -60deg)); }\n.vs-spring-toggle-glyph .off { opacity: calc(.55 - var(--p) * .55); transform: scale(calc(1 - var(--p) * .6)) rotate(calc(var(--p) * 60deg)); }\n.vs-spring-toggle:not(.vs-spring-toggle--icons) .vs-spring-toggle-glyph { display: none; }\n.vs-spring-toggle-spin {\n  position: absolute; inset: .25em; border-radius: 50%; opacity: 0; transition: opacity .2s;\n  border: .125em solid color-mix(in srgb, var(--vs-spring-toggle-on) 25%, transparent); border-top-color: var(--vs-spring-toggle-on);\n  animation: vs-spring-toggle-spin .7s linear infinite;\n}\n@keyframes vs-spring-toggle-spin { to { transform: rotate(360deg); } }\n.vs-spring-toggle[aria-busy=\"true\"] { cursor: progress; }\n.vs-spring-toggle[aria-busy=\"true\"] .vs-spring-toggle-spin { opacity: 1; }\n.vs-spring-toggle[aria-busy=\"true\"] .vs-spring-toggle-glyph { opacity: 0; }\n.vs-spring-toggle:disabled { cursor: not-allowed; opacity: .42; }\n.vs-spring-toggle:focus-visible { box-shadow: inset 0 0 0 .5px rgba(0,0,0,.06), 0 0 0 2px #fff, 0 0 0 4px var(--vs-spring-toggle-focus); }\n@media (forced-colors: active) { .vs-spring-toggle { border: 1px solid ButtonText; } .vs-spring-toggle-thumb { background: ButtonText; } }\n@media (prefers-reduced-motion: reduce) { .vs-spring-toggle-spin { animation-duration: 2s; } }\n</style>\n\n<script>\n(() => {\n  if (window.vsSpringToggle) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\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 t of live) if (!t.update(dt)) live.delete(t);\n    raf = live.size ? requestAnimationFrame(tick) : 0;\n  };\n  const wake = t => { live.add(t); if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n  const sp = (x, k, c) => ({ x, v: 0, g: x, k, c });\n  const step = (s, h) => { s.v += (-s.k * (s.x - s.g) - s.c * s.v) * h; s.x += s.v * h; };\n  const settled = s => Math.abs(s.v) < .0005 && Math.abs(s.x - s.g) < .0005;\n  const rubber = (x, lim) => x < 0 ? -lim * (1 - Math.exp(x / lim)) : x > 1 ? 1 + lim * (1 - Math.exp(-(x - 1) / lim)) : x;\n\n  function mount(el) {\n    if (el.__vsSt) return el.__vsSt;\n    el.setAttribute('role', 'switch');\n    if (!el.hasAttribute('type') && el.tagName === 'BUTTON') el.type = 'button';\n    el.innerHTML = '<span class=\"vs-spring-toggle-thumb\" aria-hidden=\"true\"><span class=\"vs-spring-toggle-glyph\">' +\n      '<svg class=\"off\" viewBox=\"0 0 12 12\"><path d=\"M3.5 3.5l5 5M8.5 3.5l-5 5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"/></svg>' +\n      '<svg class=\"on\" viewBox=\"0 0 12 12\"><path d=\"M2.6 6.3 5 8.6l4.4-5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>' +\n      '</span><span class=\"vs-spring-toggle-spin\"></span></span>';\n    const thumb = el.firstChild;\n    let input = null;\n    if (el.dataset.name) { input = document.createElement('input'); input.type = 'hidden'; input.name = el.dataset.name; el.after(input); }\n    const on0 = el.getAttribute('aria-checked') === 'true';\n    const S = { p: sp(on0 ? 1 : 0, 520, 26), w: sp(0, 700, 34), bump: sp(0, 900, 22) };\n    let checked = on0, drag = null, arrived = true;\n    const inert = () => el.disabled || el.getAttribute('aria-busy') === 'true';\n    const damping = () => { const b = Math.min(1, Math.max(0, parseFloat(el.dataset.bounce ?? .55))); return 44 - b * 32; };\n\n    const geo = () => { const em = parseFloat(getComputedStyle(el).fontSize); return { em, travel: 1.25 * em, tw: 1.625 * em }; };\n    let G = geo();\n    new ResizeObserver(() => { G = geo(); wake(api); }).observe(el);\n\n    function set(v, { user = false, velocity = 0 } = {}) {\n      const changed = v !== checked; checked = v;\n      el.setAttribute('aria-checked', String(v));\n      if (input) input.value = v ? 'on' : '';\n      S.p.g = v ? 1 : 0; S.p.v += velocity; S.p.c = damping(); arrived = false;\n      if (reduce.matches) { S.p.x = S.p.g; S.p.v = 0; }\n      wake(api);\n      if (changed && user) {\n        el.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { checked: v } }));\n        if (drag && drag.touch && navigator.vibrate) try { navigator.vibrate(10); } catch (_) {}\n      }\n    }\n    const api = {\n      set: v => set(!!v), get checked() { return checked; },\n      update(dt) {\n        let moving = false;\n        const n = Math.max(1, Math.round(dt * 120)), h = dt / n;\n        for (const k in S) {\n          const s = S[k];\n          if (k === 'p' && drag && drag.active) continue;\n          if (settled(s)) { s.x = s.g; s.v = 0; continue; }\n          moving = true;\n          if (reduce.matches) { s.x = s.g; s.v = 0; continue; }\n          const before = s.x - s.g;\n          for (let i = 0; i < n; i++) step(s, h);\n          // first crossing of the goal = the thumb lands: give the track a bump\n          if (k === 'p' && !arrived && (Math.sign(s.x - s.g) !== Math.sign(before) || Math.abs(s.x - s.g) < .01)) {\n            arrived = true; S.bump.v += Math.min(2.2, Math.abs(s.v) * .35);\n          }\n        }\n        const p = S.p.x, pc = Math.min(1, Math.max(0, p));\n        el.style.setProperty('--p', pc.toFixed(4));\n        // velocity squash-and-stretch (px/s along the travel)\n        const vpx = (drag && drag.active ? drag.v : S.p.v) * G.travel;\n        const st = Math.min(.34, Math.abs(vpx) * .00055);\n        const extra = S.w.x * G.tw * .28;                         // press: the thumb widens toward the travel\n        const x = p * G.travel - extra * pc;\n        thumb.style.width = (G.tw + extra).toFixed(2) + 'px';\n        thumb.style.transform = `translate3d(${x.toFixed(2)}px,0,0) scale(${(1 + st).toFixed(4)},${(1 - st * .55).toFixed(4)})`;\n        el.style.transform = S.bump.x ? `scale(${(1 + S.bump.x * .035).toFixed(4)},${(1 - S.bump.x * .02).toFixed(4)})` : '';\n        return moving || (drag && drag.active);\n      },\n    };\n\n    el.addEventListener('pointerdown', e => {\n      if (inert() || e.button > 0) return;\n      G = geo();\n      drag = { id: e.pointerId, x0: e.clientX, p0: S.p.x, active: false, t: performance.now(), lx: e.clientX, v: 0, touch: e.pointerType !== 'mouse' };\n      S.w.g = 1; wake(api);\n      try { el.setPointerCapture(e.pointerId); } catch (_) {}\n    });\n    el.addEventListener('pointermove', e => {\n      if (!drag || e.pointerId !== drag.id) return;\n      const dx = e.clientX - drag.x0;\n      if (!drag.active && Math.abs(dx) > 4) drag.active = true;\n      if (!drag.active) return;\n      const now = performance.now(), dtm = Math.max(1, now - drag.t);\n      const vp = (e.clientX - drag.lx) / G.travel / (dtm / 1000);\n      drag.v = drag.v * .6 + vp * .4; drag.t = now; drag.lx = e.clientX;\n      S.p.x = rubber(drag.p0 + dx / G.travel, .18); S.p.v = 0;\n      wake(api);\n    });\n    const end = e => {\n      if (!drag || e.pointerId !== drag.id) return;\n      S.w.g = 0;\n      if (drag.active) {\n        const proj = S.p.x + drag.v * .12;\n        const v = drag.v; drag.active = false; S.p.v = v;\n        set(proj > .5, { user: true });\n      } else if (e.type === 'pointerup') set(!checked, { user: true });\n      drag = null; wake(api);\n    };\n    el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);\n    el.addEventListener('click', e => { if (e.detail === 0 && !inert()) set(!checked, { user: true }); });   // keyboard (Enter/Space)\n    el.addEventListener('keydown', e => {\n      if (inert()) return;\n      if (e.key === 'ArrowRight' && !checked) { e.preventDefault(); set(true, { user: true }); }\n      if (e.key === 'ArrowLeft' && checked) { e.preventDefault(); set(false, { user: true }); }\n    });\n    new MutationObserver(() => { const v = el.getAttribute('aria-checked') === 'true'; if (v !== checked) set(v); })\n      .observe(el, { attributes: true, attributeFilter: ['aria-checked'] });\n    el.__vsSt = api; set(on0); S.p.x = S.p.g; api.update(0);\n    return api;\n  }\n  const init = () => document.querySelectorAll('.vs-spring-toggle').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsSpringToggle = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Buttons & inputs",
    "tags": [
      "toggle",
      "switch",
      "spring",
      "squash and stretch",
      "drag",
      "form",
      "accessibility"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#2fcf6b",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/spring-toggle/index.html",
    "thumbnail": "https://vividsites.app/api/component/spring-toggle/thumb.jpg",
    "video": "https://vividsites.app/api/component/spring-toggle/thumb.mp4",
    "page": "https://vividsites.app/c/spring-toggle"
  }
}