{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "magnetic-button",
  "type": "registry:block",
  "title": "Magnetic Button",
  "description": "A button that leans toward the cursor on a spring and squashes along its own velocity, with a label that drifts further for parallax. A colour flood pours in from the exact point the pointer entered and drains out where it left.",
  "docs": "Magnetic Button was added to public/vividsites/magnetic-button.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/magnetic-button",
  "categories": [
    "ui",
    "buttons-inputs"
  ],
  "files": [
    {
      "path": "components/vividsites/magnetic-button.html",
      "type": "registry:file",
      "target": "public/vividsites/magnetic-button.html",
      "content": "<!--\nvs-magnetic-button: a button that leans toward the cursor on a spring and squashes a\n  little along its own velocity. The label drifts further than the body (parallax), and a\n  colour flood pours in from the point the pointer entered and drains out where it left.\n\n  Markup:  <a class=\"vs-magnetic-button\" href=\"#\">Label</a>   (a <button> works too)\n  Any inline HTML is fine inside (an svg arrow, a span). The script builds the layers.\n\n  Per-button attributes (all optional):\n    data-strength  how far the body follows the pointer, 0–1      (default .4)\n    data-field     reach of the pull beyond the edge, px          (default 70)\n    data-parallax  extra travel of the label on top of the body   (default .55)\n    data-jelly     squash-and-stretch along the velocity, 0–1     (default .5)\n\n  States: :hover flood · press squeeze · :focus-visible ring\n          aria-busy=\"true\" → spinner, pull off · disabled / aria-disabled=\"true\" → dimmed, inert\n  Modifiers: .vs-magnetic-button--ghost (outline) · .vs-magnetic-button--round (circle)\n  Customise with the CSS variables on .vs-magnetic-button. Size everything with font-size.\n  Reduced motion: no pull, the flood fades instead of pouring.\n-->\n<a class=\"vs-magnetic-button\" href=\"#\">\n  Start a project\n  <svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M3 8h9M8.5 4 12.5 8l-4 4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>\n</a>\n\n<style>\n.vs-magnetic-button {\n  --vs-magnetic-button-bg: #16130f;            /* body fill */\n  --vs-magnetic-button-ink: #f6f1e8;           /* label colour */\n  --vs-magnetic-button-flood: #ff5b2e;         /* colour that pours in on hover */\n  --vs-magnetic-button-flood-ink: #16130f;     /* label colour under the flood */\n  --vs-magnetic-button-line: rgba(22,19,15,.22); /* ghost variant border */\n  --vs-magnetic-button-radius: 999px;\n  --vs-magnetic-button-focus: #ff5b2e;\n  --vs-magnetic-button-font-size: 1rem;        /* scales padding, icon and round size too */\n\n  position: relative; display: inline-flex; vertical-align: middle;\n  font-size: var(--vs-magnetic-button-font-size);\n  text-decoration: none; color: var(--vs-magnetic-button-ink);\n  border: 0; padding: 0; background: none; cursor: pointer;\n  font-family: inherit; -webkit-tap-highlight-color: transparent; touch-action: manipulation;\n  border-radius: var(--vs-magnetic-button-radius); outline: none; user-select: none; -webkit-user-select: none;\n}\n.vs-magnetic-button, .vs-magnetic-button * { box-sizing: border-box; }\n.vs-magnetic-button-body {\n  position: relative; display: inline-flex; overflow: hidden; isolation: isolate;\n  border-radius: inherit; background: var(--vs-magnetic-button-bg); pointer-events: none;\n  will-change: transform; transform: translate3d(0,0,0);\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.14), inset 0 -1px 0 rgba(0,0,0,.25),\n    0 1px 1px rgba(0,0,0,.08), 0 6px 14px -6px rgba(0,0,0,.28), 0 18px 36px -18px rgba(0,0,0,.4);\n  transition: box-shadow .25s cubic-bezier(.2,0,0,1), opacity .25s;\n}\n.vs-magnetic-button.is-down .vs-magnetic-button-body {\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.08), inset 0 2px 6px rgba(0,0,0,.28), 0 1px 1px rgba(0,0,0,.08), 0 3px 8px -4px rgba(0,0,0,.3);\n}\n.vs-magnetic-button-face {\n  display: inline-flex; align-items: center; justify-content: center; gap: .55em;\n  padding: 1.02em 1.6em 1.08em; white-space: nowrap;\n  font-weight: 500; line-height: 1; letter-spacing: -.01em;\n}\n.vs-magnetic-button-label { display: inline-flex; align-items: center; gap: .55em; will-change: transform; transition: opacity .25s, filter .25s; }\n.vs-magnetic-button-label svg { width: 1em; height: 1em; transition: transform .5s cubic-bezier(.16,1,.3,1); }\n.vs-magnetic-button.is-hot .vs-magnetic-button-label svg { transform: translateX(.2em); }\n.vs-magnetic-button-flood {\n  position: absolute; border-radius: 50%; overflow: hidden; pointer-events: none; z-index: 1;\n  background: var(--vs-magnetic-button-flood); transform: scale(0); will-change: transform;\n}\n.vs-magnetic-button-flood > .vs-magnetic-button-face { position: absolute; color: var(--vs-magnetic-button-flood-ink); will-change: transform; }\n.vs-magnetic-button-glow {\n  position: absolute; inset: -.9em; border-radius: inherit; pointer-events: none; opacity: 0;\n  background: radial-gradient(closest-side, color-mix(in srgb, var(--vs-magnetic-button-flood) 42%, transparent), transparent);\n  filter: blur(12px); will-change: transform, opacity;\n}\n.vs-magnetic-button-spin {\n  position: absolute; left: 50%; top: 50%; width: 1.3em; height: 1.3em; margin: -.65em 0 0 -.65em; z-index: 2;\n  opacity: 0; transform: scale(.6); transition: opacity .25s, transform .45s cubic-bezier(.3,1.4,.5,1); color: inherit;\n}\n.vs-magnetic-button-spin circle { fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-dasharray: 15 42; transform-origin: 50% 50%; animation: vs-magnetic-button-spin .8s linear infinite; }\n\n.vs-magnetic-button-spin circle.t { stroke-dasharray: none; opacity: .22; animation: none; }\n@keyframes vs-magnetic-button-spin { to { transform: rotate(360deg); } }\n.vs-magnetic-button[aria-busy=\"true\"] { cursor: progress; }\n.vs-magnetic-button[aria-busy=\"true\"] .vs-magnetic-button-label { opacity: 0; filter: blur(4px); }\n.vs-magnetic-button[aria-busy=\"true\"] .vs-magnetic-button-spin { opacity: 1; transform: scale(1); }\n.vs-magnetic-button:disabled, .vs-magnetic-button[aria-disabled=\"true\"] { cursor: not-allowed; }\n.vs-magnetic-button:disabled .vs-magnetic-button-body, .vs-magnetic-button[aria-disabled=\"true\"] .vs-magnetic-button-body { opacity: .4; box-shadow: none; }\n.vs-magnetic-button:focus-visible .vs-magnetic-button-body {\n  box-shadow: 0 0 0 2px var(--vs-magnetic-button-bg), 0 0 0 4px var(--vs-magnetic-button-focus), 0 6px 14px -6px rgba(0,0,0,.28);\n}\n/* ghost: outline only, the flood fills it */\n.vs-magnetic-button--ghost { color: var(--vs-magnetic-button-bg); }\n.vs-magnetic-button--ghost .vs-magnetic-button-body { background: transparent; box-shadow: inset 0 0 0 1px var(--vs-magnetic-button-line); }\n.vs-magnetic-button--ghost.is-down .vs-magnetic-button-body { box-shadow: inset 0 0 0 1px var(--vs-magnetic-button-line), inset 0 2px 6px rgba(0,0,0,.12); }\n.vs-magnetic-button--ghost:focus-visible .vs-magnetic-button-body { box-shadow: inset 0 0 0 1px var(--vs-magnetic-button-line), 0 0 0 4px var(--vs-magnetic-button-focus); }\n/* round: a fixed circle */\n.vs-magnetic-button--round .vs-magnetic-button-body { width: 4.25em; height: 4.25em; border-radius: 50%; }\n.vs-magnetic-button--round .vs-magnetic-button-face { width: 100%; height: 100%; padding: 0; }\n.vs-magnetic-button--round .vs-magnetic-button-label svg { width: 1.3em; height: 1.3em; }\n.vs-magnetic-button--round.is-hot .vs-magnetic-button-label svg { transform: rotate(-45deg); }\n@media (forced-colors: active) { .vs-magnetic-button:focus-visible { outline: 2px solid CanvasText; outline-offset: 4px; } }\n@media (prefers-reduced-motion: reduce) { .vs-magnetic-button-spin circle { animation-duration: 2.4s; } }\n</style>\n\n<script>\n(() => {\n  if (window.vsMagneticButton) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const fine = matchMedia('(hover: hover) and (pointer: fine)');\n  // semi-implicit Euler spring (tension / friction), fixed 1/120 s substeps\n  const spring = (x, k, c) => ({ x, v: 0, g: x, k, c });\n  const stepS = (s, h) => { s.v += (-s.k * (s.x - s.g) - s.c * s.v) * h; s.x += s.v * h; };\n  const rest = s => Math.abs(s.v) < .004 && Math.abs(s.x - s.g) < .004;\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 b of live) if (!b.update(dt)) live.delete(b);\n    raf = live.size ? requestAnimationFrame(tick) : 0;\n  };\n  const wake = b => { live.add(b); if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n  const all = new Set();\n  addEventListener('pointermove', e => { if (e.pointerType === 'mouse' && fine.matches) for (const b of all) b.pull(e.clientX, e.clientY); }, { passive: true });\n  document.documentElement.addEventListener('pointerleave', () => { for (const b of all) b.release(); });\n\n  function mount(el) {\n    if (el.__vsMb) return el.__vsMb;\n    const num = (k, d) => { const v = parseFloat(el.dataset[k]); return isNaN(v) ? d : v; };\n    const body = document.createElement('span'); body.className = 'vs-magnetic-button-body';\n    const face = document.createElement('span'); face.className = 'vs-magnetic-button-face';\n    const label = document.createElement('span'); label.className = 'vs-magnetic-button-label';\n    while (el.firstChild) label.appendChild(el.firstChild);\n    face.appendChild(label); body.appendChild(face);\n    const flood = document.createElement('span'); flood.className = 'vs-magnetic-button-flood'; flood.setAttribute('aria-hidden', 'true');\n    const face2 = face.cloneNode(true); flood.appendChild(face2); body.appendChild(flood);\n    const label2 = face2.firstChild;\n    body.insertAdjacentHTML('beforeend', '<svg class=\"vs-magnetic-button-spin\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle class=\"t\" cx=\"12\" cy=\"12\" r=\"9\"/><circle cx=\"12\" cy=\"12\" r=\"9\"/></svg>');\n    const glow = document.createElement('span'); glow.className = 'vs-magnetic-button-glow'; glow.setAttribute('aria-hidden', 'true');\n    el.append(glow, body);\n\n    const S = { x: spring(0, 170, 12), y: spring(0, 170, 12), lx: spring(0, 210, 14), ly: spring(0, 210, 14),\n      sc: spring(1, 380, 20), fl: spring(0, 170, 24), gl: spring(0, 120, 20) };\n    let visible = true, down = false, R = 0;\n    new IntersectionObserver(e => { visible = e[0].isIntersecting; if (!visible) api.release(); }).observe(el);\n    const inert = () => el.disabled || el.getAttribute('aria-disabled') === 'true' || el.getAttribute('aria-busy') === 'true';\n\n    const placeFlood = (px, py) => {\n      const w = body.offsetWidth, h = body.offsetHeight;\n      R = Math.hypot(Math.max(px, w - px), Math.max(py, h - py)) + 2;\n      flood.style.cssText = `left:${px - R}px;top:${py - R}px;width:${2 * R}px;height:${2 * R}px`;\n      Object.assign(face2.style, { left: `${R - px}px`, top: `${R - py}px`, width: `${w}px`, height: `${h}px`, transformOrigin: `${px}px ${py}px` });\n    };\n    const local = e => { const r = body.getBoundingClientRect(), t = body.style.transform; return [e.clientX - r.left, e.clientY - r.top]; };\n\n    const api = {\n      pull(cx, cy) {\n        if (reduce.matches || !visible || inert()) return api.release();\n        const strength = num('strength', .4), field = num('field', 70), para = num('parallax', .55);\n        const r = el.getBoundingClientRect();\n        const mx = r.left + r.width / 2, my = r.top + r.height / 2;\n        const edge = Math.hypot(Math.max(r.left - cx, 0, cx - r.right), Math.max(r.top - cy, 0, cy - r.bottom));\n        let f = 1 - Math.min(1, edge / field); f = f * f * (3 - 2 * f);\n        if (f <= 0 && S.x.g === 0 && S.y.g === 0) return;\n        const cap = body.offsetHeight * .5;                                  // soft cap so neighbours never collide\n        const dx = cap * Math.tanh((cx - mx) * strength * f / cap), dy = cap * Math.tanh((cy - my) * strength * f / cap);\n        const lim = Math.min(body.offsetWidth, body.offsetHeight) * .17;   // label stays inside the shape\n        S.x.g = dx; S.y.g = dy; S.lx.g = lim * Math.tanh(dx * para / lim); S.ly.g = lim * Math.tanh(dy * para / lim); S.gl.g = f;\n        wake(api);\n      },\n      release() {\n        if (S.x.g === 0 && S.y.g === 0 && S.gl.g === 0 && S.sc.g === 1) return;\n        S.x.g = S.y.g = S.lx.g = S.ly.g = S.gl.g = 0; S.sc.g = 1; wake(api);\n      },\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) { const s = S[k]; if (rest(s)) { s.x = s.g; s.v = 0; continue; } moving = true; for (let i = 0; i < n; i++) stepS(s, h); }\n        if (reduce.matches) for (const k in S) { S[k].x = S[k].g; S[k].v = 0; }\n        // squash-and-stretch along the velocity of the body\n        const jelly = num('jelly', .5), vx = S.x.v, vy = S.y.v, sp = Math.hypot(vx, vy);\n        const st = Math.min(.09, sp * .00022 * jelly * 2), ang = Math.atan2(vy, vx) * 57.2958;\n        const sc = S.sc.x;\n        body.style.transform = `translate3d(${S.x.x.toFixed(2)}px,${S.y.x.toFixed(2)}px,0) rotate(${ang.toFixed(2)}deg) scale(${(sc * (1 + st)).toFixed(4)},${(sc * (1 - st * .7)).toFixed(4)}) rotate(${(-ang).toFixed(2)}deg)`;\n        glow.style.transform = `translate3d(${(S.x.x * 1.2).toFixed(2)}px,${(S.y.x * 1.2).toFixed(2)}px,0)`;\n        glow.style.opacity = (Math.max(0, S.gl.x) * .85).toFixed(3);\n        const lt = `translate3d(${S.lx.x.toFixed(2)}px,${S.ly.x.toFixed(2)}px,0)`;\n        label.style.transform = lt; label2.style.transform = lt;\n        const f = Math.max(.0005, Math.min(1.08, S.fl.x));\n        if (reduce.matches) { flood.style.transform = 'scale(1)'; flood.style.opacity = S.fl.g; face2.style.transform = 'none'; }\n        else { flood.style.opacity = ''; flood.style.transform = `scale(${f.toFixed(4)})`; face2.style.transform = `scale(${(1 / f).toFixed(4)})`; }\n        return moving;\n      },\n    };\n    const enter = e => { if (inert()) return; el.classList.add('is-hot'); const [px, py] = local(e); if (S.fl.x < .02) placeFlood(px, py); S.fl.g = 1; wake(api); };\n    const leave = e => { el.classList.remove('is-hot'); if (S.fl.x > .96) { const [px, py] = local(e); placeFlood(px, py); } S.fl.g = 0; wake(api); };\n    el.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') enter(e); });\n    el.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse') leave(e); });\n    el.addEventListener('pointerdown', e => {\n      if (inert()) return; down = true; el.classList.add('is-down'); S.sc.g = .94; wake(api);\n      if (e.pointerType !== 'mouse') { enter(e); api.pull(e.clientX, e.clientY); try { el.setPointerCapture(e.pointerId); } catch (_) {} }\n    });\n    el.addEventListener('pointermove', e => { if (down && e.pointerType !== 'mouse') api.pull(e.clientX, e.clientY); });\n    const up = e => {\n      if (!down) return; down = false; el.classList.remove('is-down'); S.sc.g = 1; S.sc.v += 1.4; wake(api);\n      if (e.pointerType !== 'mouse') { api.release(); leave(e); }\n    };\n    el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up);\n    el.addEventListener('keydown', e => { if ((e.key === ' ' || e.key === 'Enter') && !inert()) { el.classList.add('is-down'); S.sc.g = .94; wake(api); } });\n    el.addEventListener('keyup', () => { el.classList.remove('is-down'); S.sc.g = 1; wake(api); });\n    el.addEventListener('blur', () => { el.classList.remove('is-down'); S.sc.g = 1; wake(api); });\n    new MutationObserver(() => { if (inert()) { api.release(); el.classList.remove('is-hot'); if (S.fl.x > .96) placeFlood(body.offsetWidth / 2, body.offsetHeight / 2); S.fl.g = 0; wake(api); } }).observe(el, { attributes: true, attributeFilter: ['aria-busy', 'aria-disabled', 'disabled'] });\n    all.add(api); el.__vsMb = api; return api;\n  }\n  const init = () => document.querySelectorAll('.vs-magnetic-button').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsMagneticButton = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Buttons & inputs",
    "tags": [
      "button",
      "magnetic",
      "spring",
      "hover",
      "cursor",
      "parallax",
      "cta",
      "loading"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#ff5b2e",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/magnetic-button/index.html",
    "thumbnail": "https://vividsites.app/api/component/magnetic-button/thumb.jpg",
    "video": "https://vividsites.app/api/component/magnetic-button/thumb.mp4",
    "page": "https://vividsites.app/c/magnetic-button"
  }
}