{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ring-cursor",
  "type": "registry:block",
  "title": "Ring Cursor",
  "description": "A dot-and-ring cursor whose ring morphs into the rounded rectangle of whatever it hovers, matching its radius, while the element leans toward the pointer on its own spring and the ring rubber-bands inside it. Press tightens it, Tab glides the same ring between focused controls, and touch keeps the native cursor.",
  "docs": "Ring Cursor was added to public/vividsites/ring-cursor.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/ring-cursor",
  "categories": [
    "ui",
    "loaders-cursors-feedback"
  ],
  "files": [
    {
      "path": "components/vividsites/ring-cursor.html",
      "type": "registry:file",
      "target": "public/vividsites/ring-cursor.html",
      "content": "<!--\n  vs-ring-cursor: a magnetic ring cursor that snaps to interactive elements.\n  A precise dot sits on the pointer and a ring follows it on a real spring (semi-implicit Euler).\n  Over a link or button the ring MORPHS into that element's rounded rectangle (its own\n  border-radius, plus padding) and the element itself leans a few px toward the pointer on its\n  own spring, returning when you leave. While snapped, the ring still rubber-bands after the\n  pointer. Pressing tightens it. Keyboard focus (:focus-visible) draws the same ring around the\n  focused element, so Tab glides the ring from control to control.\n  Fine pointers only: on touch / coarse pointers nothing is drawn and native cursors and focus\n  rings stay. It hides when the pointer leaves the window, and its rAF loop sleeps when settled.\n\n  Markup: paste the <svg class=\"vs-ring-cursor\"> once, anywhere in <body>.\n  Targets: a[href], button, [role=button|tab|radio|switch|menuitem], summary, label[for],\n    and anything with [data-vs-ring-cursor].\n    data-vs-ring-cursor=\"ring\"    this element uses ring mode (see below) whatever the page mode\n    data-vs-ring-cursor=\"morph\"   this element morphs whatever the page mode\n    data-vs-ring-cursor=\"none\"    no custom cursor here (native cursor shows)\n    data-vs-ring-cursor-pad=\"10\"  per-element padding in px\n  Text fields and contenteditable keep the native I-beam. Disabled targets do not snap.\n  Page mode: <html data-vs-ring-cursor-mode=\"morph|ring\">\n    morph (default)  the ring becomes the element's rounded rect, the dot fades into it\n    ring             the ring stays round, swells a little and snaps to the element's centre\n\n  Variables (read live from :root; change them in CSS or inline and the cursor follows):\n    --vs-ring-cursor-accent     ring and dot colour                            #62e3ff\n    --vs-ring-cursor-size       ring diameter when free                        34px\n    --vs-ring-cursor-dot        dot diameter                                   6px\n    --vs-ring-cursor-pad        padding around a snapped element               6px\n    --vs-ring-cursor-stroke     ring stroke width                              1.5px\n    --vs-ring-cursor-fill       highlight fill opacity when snapped, 0-1       .15\n    --vs-ring-cursor-magnet     how far elements lean toward the pointer, 0-1  .6  (1 = 10px)\n    --vs-ring-cursor-stiffness  ring spring stiffness; lower = more lag        520\n  prefers-reduced-motion: no element movement, no rubber-band, no overshoot.\n-->\n<style>\n  :root {\n    --vs-ring-cursor-accent: #62e3ff;\n    --vs-ring-cursor-size: 34px;\n    --vs-ring-cursor-dot: 6px;\n    --vs-ring-cursor-pad: 6px;\n    --vs-ring-cursor-stroke: 1.5px;\n    --vs-ring-cursor-fill: .15;\n    --vs-ring-cursor-magnet: .6;\n    --vs-ring-cursor-stiffness: 520;\n  }\n  .vs-ring-cursor {\n    position: fixed; left: 0; top: 0; width: 100vw; height: 100vh; overflow: visible;\n    z-index: 2147483000; pointer-events: none; opacity: 0;\n    transition: opacity .25s cubic-bezier(.2, 0, 0, 1);\n  }\n  .vs-ring-cursor.is-on { opacity: 1; }\n  .vs-ring-cursor-ring {\n    fill: var(--vs-ring-cursor-accent); fill-opacity: 0;\n    stroke: var(--vs-ring-cursor-accent); stroke-width: var(--vs-ring-cursor-stroke);\n    shape-rendering: geometricPrecision;\n  }\n  .vs-ring-cursor-halo { fill: none; stroke: var(--vs-ring-cursor-accent); stroke-width: 7; stroke-opacity: 0; }\n  .vs-ring-cursor-dot { fill: var(--vs-ring-cursor-accent); }\n  .vs-ring-cursor.is-dim .vs-ring-cursor-ring { stroke-opacity: .4; }\n  @media (hover: hover) and (pointer: fine) {\n    html.vs-ring-cursor-live, html.vs-ring-cursor-live * { cursor: none !important; }\n    html.vs-ring-cursor-live :is(input:not([type=\"button\"]):not([type=\"submit\"]):not([type=\"reset\"]):not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"range\"]), textarea, [contenteditable=\"\"], [contenteditable=\"true\"], [data-vs-ring-cursor=\"none\"]),\n    html.vs-ring-cursor-live [data-vs-ring-cursor=\"none\"] * { cursor: auto !important; }\n    html.vs-ring-cursor-focus :focus-visible { outline-color: transparent !important; }\n  }\n  @media not all and (pointer: fine) { .vs-ring-cursor { display: none; } }\n</style>\n\n<svg class=\"vs-ring-cursor\" aria-hidden=\"true\" focusable=\"false\"><rect class=\"vs-ring-cursor-halo\"/><rect class=\"vs-ring-cursor-ring\"/><circle class=\"vs-ring-cursor-dot\" r=\"3\"/></svg>\n\n<script>\n(() => {\n  const svg = document.querySelector('.vs-ring-cursor');\n  if (!svg || svg.__vs) return; svg.__vs = 1;\n  const root = document.documentElement;\n  const ring = svg.querySelector('.vs-ring-cursor-ring'), halo = svg.querySelector('.vs-ring-cursor-halo'), dot = svg.querySelector('.vs-ring-cursor-dot');\n  const fine = matchMedia('(hover: hover) and (pointer: fine)');\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const SEL = 'a[href], button, [role=\"button\"], [role=\"tab\"], [role=\"radio\"], [role=\"switch\"], [role=\"menuitem\"], summary, label[for], [data-vs-ring-cursor]';\n  const NATIVE = 'input:not([type=\"button\"]):not([type=\"submit\"]):not([type=\"reset\"]):not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"range\"]), textarea, [contenteditable=\"\"], [contenteditable=\"true\"], [data-vs-ring-cursor=\"none\"]';\n\n  let cfg = {};\n  const read = () => {\n    const cs = getComputedStyle(svg), n = (k, d) => { const v = parseFloat(cs.getPropertyValue(k)); return isNaN(v) ? d : v; };\n    cfg = {\n      size: n('--vs-ring-cursor-size', 34), dot: n('--vs-ring-cursor-dot', 6), pad: n('--vs-ring-cursor-pad', 6),\n      fill: n('--vs-ring-cursor-fill', .12), magnet: n('--vs-ring-cursor-magnet', .6),\n      k: Math.max(40, n('--vs-ring-cursor-stiffness', 520)),\n      mode: root.getAttribute('data-vs-ring-cursor-mode') || 'morph',\n    };\n    if (cur) { measure(cur); curMode = modeOf(cur); } if (kb) measure(kb);\n    kick();\n  };\n\n  // ring spring (centre, size, radius), dot scale, highlight fill; one magnet spring per element\n  const R = { x: 0, y: 0, w: 0, h: 0, r: 0 }, RV = { x: 0, y: 0, w: 0, h: 0, r: 0 };\n  const DS = { s: 0, v: 0 }, FO = { s: 0, v: 0 };\n  const mags = new Map();\n  let px = -99, py = -99, inside = false, shown = false, pressed = false, live = false, raf = 0, last = 0;\n  let cur = null, curMode = 'morph', dim = false, kb = null, kbMode = false, ax = 0, ay = 0;\n\n  const radiusOf = (el, w, h) => {\n    const v = getComputedStyle(el).borderTopLeftRadius, m = Math.min(w, h) / 2;\n    const f = parseFloat(v) || 0;\n    return Math.min(m, /%/.test(v) ? Math.min(w, h) * f / 100 : f);\n  };\n  const measure = (el) => {\n    const r = el.getBoundingClientRect(), m = mags.get(el), ox = m ? m.x : 0, oy = m ? m.y : 0;\n    const pad = parseFloat(el.getAttribute('data-vs-ring-cursor-pad'));\n    el.__vsr = { x: r.left - ox, y: r.top - oy, w: r.width, h: r.height, r: radiusOf(el, r.width, r.height), pad: isNaN(pad) ? null : pad };\n  };\n  const modeOf = (el) => { const a = el.getAttribute('data-vs-ring-cursor'); return a === 'ring' || a === 'morph' ? a : cfg.mode; };\n  const mag = (el) => { let m = mags.get(el); if (!m) { m = { x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0 }; mags.set(el, m); } return m; };\n\n  const resolve = (t) => {\n    const nat = t && t.closest ? t.closest(NATIVE) : null;\n    let hit = !nat && t && t.closest ? t.closest(SEL) : null;\n    const off = hit && (hit.disabled || hit.getAttribute('aria-disabled') === 'true');\n    if (off) hit = null;\n    if (dim !== !!off) { dim = !!off; svg.classList.toggle('is-dim', dim); }\n    if (!!nat === shown && inside) show(!nat);\n    if (hit === cur) return;\n    if (cur && mags.has(cur)) { const m = mags.get(cur); m.tx = m.ty = 0; }\n    cur = hit;\n    if (cur) { measure(cur); curMode = modeOf(cur); }\n    kick();\n  };\n\n  const show = (on) => {\n    if (on === shown) return; shown = on;\n    svg.classList.toggle('is-on', on);\n    if (on && !live && inside) { live = true; root.classList.add('vs-ring-cursor-live'); }\n    kick();\n  };\n  const focusRing = (on) => root.classList.toggle('vs-ring-cursor-focus', on);\n\n  const sp = (o, v, key, target, k, c, h) => { v[key] += (k * (target - o[key]) - c * v[key]) * h; o[key] += v[key] * h; };\n  const sp1 = (o, target, k, c, h) => { o.v += (k * (target - o.s) - c * o.v) * h; o.s += o.v * h; };\n\n  const frame = (now) => {\n    raf = 0;\n    const dt = Math.min(1 / 30, (now - last) / 1000 || 1 / 60); last = now;\n    const rm = reduce.matches;\n    const el = kbMode && kb ? kb : cur;\n    let tx = px, ty = py, tw = cfg.size, th = cfg.size, tr = cfg.size / 2, tds = 1, tfo = 0;\n    if (pressed && !el) { tw = th = cfg.size * .72; tr = tw / 2; tds = .8; }\n\n    if (el && el.__vsr) {\n      const b = el.__vsr, m = mag(el), mode = kbMode ? 'morph' : curMode;\n      const cx0 = b.x + b.w / 2, cy0 = b.y + b.h / 2;\n      const nx = kbMode ? 0 : Math.max(-1, Math.min(1, (px - cx0) / (b.w / 2)));\n      const ny = kbMode ? 0 : Math.max(-1, Math.min(1, (py - cy0) / (b.h / 2)));\n      // element magnet: a few px toward the pointer, on its own spring\n      const M = rm ? 0 : cfg.magnet * 10;\n      m.tx = nx * M * Math.min(1, b.w / 60 + .4) * .9; m.ty = ny * M * .7;\n      const cx = cx0 + m.x, cy = cy0 + m.y;\n      // rubber-band: the ring leans after the pointer inside the snapped state\n      const lean = rm || kbMode ? 0 : 3 + cfg.magnet * 5;\n      const lx = Math.tanh((px - cx) / Math.max(24, b.w / 2)) * lean, ly = Math.tanh((py - cy) / Math.max(24, b.h / 2)) * lean;\n      const pad = (b.pad == null ? cfg.pad : b.pad) * (pressed ? .35 : 1);\n      if (mode === 'ring') {\n        const d = cfg.size * (pressed ? 1.15 : 1.45);\n        tx = cx + lx * 1.4; ty = cy + ly * 1.4; tw = th = d; tr = d / 2; tds = pressed ? .8 : 1; tfo = cfg.fill * .6;\n      } else {\n        tx = cx + lx; ty = cy + ly; tw = b.w + pad * 2; th = b.h + pad * 2; tr = b.r + pad; tds = 0;\n        tfo = cfg.fill * (pressed ? 1.8 : 1);\n      }\n      if (kbMode) { tfo = cfg.fill * .5; }\n    }\n    if (!shown) tds = 0;\n\n    const k = rm ? 1600 : cfg.k, c = 2 * Math.sqrt(k) * (rm ? 1 : .74);\n    const kd = rm ? 1600 : 700, cd = 2 * Math.sqrt(kd) * (rm ? 1 : .6);\n    for (let i = 0; i < 2; i++) {\n      const h = dt / 2;\n      sp(R, RV, 'x', tx, k, c, h); sp(R, RV, 'y', ty, k, c, h);\n      sp(R, RV, 'w', tw, k, c * 1.05, h); sp(R, RV, 'h', th, k, c * 1.05, h); sp(R, RV, 'r', tr, k, c * 1.05, h);\n      sp1(DS, tds, kd, cd, h); sp1(FO, tfo, 300, 2 * Math.sqrt(300), h);\n      for (const m of mags.values()) { const km = 260, cm = 2 * Math.sqrt(km) * .55; m.vx += (km * (m.tx - m.x) - cm * m.vx) * h; m.x += m.vx * h; m.vy += (km * (m.ty - m.y) - cm * m.vy) * h; m.y += m.vy * h; }\n    }\n    let busy = Math.abs(tx - R.x) + Math.abs(ty - R.y) + Math.abs(tw - R.w) + Math.abs(th - R.h) + Math.abs(tr - R.r) > .05 ||\n      Math.abs(RV.x) + Math.abs(RV.y) + Math.abs(RV.w) + Math.abs(RV.h) > .3 || Math.abs(tds - DS.s) > .002 || Math.abs(DS.v) > .01 || Math.abs(tfo - FO.s) > .001;\n    for (const [e, m] of mags) {\n      const moving = Math.abs(m.tx - m.x) + Math.abs(m.ty - m.y) > .02 || Math.abs(m.vx) + Math.abs(m.vy) > .05;\n      if (!moving && !m.tx && !m.ty && e !== cur && e !== kb) { e.style.translate = ''; mags.delete(e); continue; }\n      e.style.translate = `${m.x.toFixed(2)}px ${m.y.toFixed(2)}px`;\n      if (moving) busy = true;\n    }\n\n    const w = Math.max(0, R.w), hh = Math.max(0, R.h), r = Math.max(0, Math.min(R.r, w / 2, hh / 2));\n    const set = (e, x, y, W, H, rx) => { e.setAttribute('x', x.toFixed(2)); e.setAttribute('y', y.toFixed(2)); e.setAttribute('width', W.toFixed(2)); e.setAttribute('height', H.toFixed(2)); e.setAttribute('rx', rx.toFixed(2)); };\n    set(ring, R.x - w / 2, R.y - hh / 2, w, hh, r);\n    set(halo, R.x - w / 2 - 2, R.y - hh / 2 - 2, w + 4, hh + 4, r + 2);\n    const fo = Math.max(0, FO.s);\n    ring.style.fillOpacity = fo.toFixed(3); halo.style.strokeOpacity = (fo * .9).toFixed(3);\n    const ds = Math.max(0, DS.s);\n    dot.setAttribute('cx', px.toFixed(2)); dot.setAttribute('cy', py.toFixed(2)); dot.setAttribute('r', (cfg.dot / 2 * ds).toFixed(2));\n    if (busy) raf = requestAnimationFrame(frame);\n  };\n  function kick() { if (!raf && fine.matches) { last = performance.now(); raf = requestAnimationFrame(frame); } }\n\n  const onMove = (e) => {\n    if (e.pointerType === 'touch') return offTouch();\n    const moved = Math.abs(e.clientX - ax) + Math.abs(e.clientY - ay) > 4;\n    px = e.clientX; py = e.clientY;\n    if (kbMode && moved) { kbMode = false; focusRing(false); }\n    if (!inside || !shown) {\n      inside = true;\n      if (!cur && !kbMode) { R.x = px; R.y = py; RV.x = RV.y = 0; if (R.w < 1) { R.w = R.h = cfg.size; R.r = cfg.size / 2; } }\n      show(true);\n    }\n    resolve(e.target); kick();\n  };\n  const offTouch = () => { inside = false; show(false); live = false; root.classList.remove('vs-ring-cursor-live'); };\n  const onDown = (e) => { if (e.pointerType === 'touch') return offTouch(); ax = e.clientX; ay = e.clientY; if (e.button === 0) { pressed = true; kick(); } };\n  const onUp = () => { if (pressed) { pressed = false; if (cur) measure(cur); kick(); } };\n  const leave = () => { inside = false; pressed = false; if (!kbMode) show(false); resolve(null); };\n  const remeasure = () => { if (cur) measure(cur); if (kb) measure(kb); kick(); };\n\n  const onFocus = (e) => {\n    const t = e.target;\n    if (!(t instanceof Element) || t.matches(NATIVE)) return;\n    let fv = false; try { fv = t.matches(':focus-visible'); } catch (_) {}\n    if (!fv) return;\n    kb = t; kbMode = true; ax = px; ay = py; measure(kb);\n    focusRing(true);\n    if (!shown) { R.x = kb.__vsr.x + kb.__vsr.w / 2; R.y = kb.__vsr.y + kb.__vsr.h / 2; R.w = R.h = 0; R.r = 0; show(true); }\n    kick();\n  };\n  const onBlur = (e) => {\n    if (e.target !== kb) return;\n    const m = mags.get(kb); if (m) { m.tx = m.ty = 0; }\n    kb = null; focusRing(false);\n    if (kbMode) { kbMode = false; if (!inside) show(false); }\n    kick();\n  };\n\n  const enable = () => {\n    read();\n    addEventListener('pointermove', onMove, { passive: true });\n    addEventListener('pointerdown', onDown, { passive: true });\n    addEventListener('pointerup', onUp, { passive: true });\n    addEventListener('pointercancel', onUp, { passive: true });\n    addEventListener('scroll', remeasure, { passive: true, capture: true });\n    addEventListener('resize', remeasure, { passive: true });\n    addEventListener('blur', () => { pressed = false; kick(); });\n    root.addEventListener('mouseleave', leave);\n    document.addEventListener('focusin', onFocus);\n    document.addEventListener('focusout', onBlur);\n    new MutationObserver(read).observe(root, { attributes: true, attributeFilter: ['style', 'data-vs-ring-cursor-mode'] });\n    reduce.addEventListener?.('change', kick);\n  };\n  if (fine.matches) enable();\n  else fine.addEventListener?.('change', function once() { if (fine.matches) { fine.removeEventListener('change', once); enable(); } });\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Loaders, cursors & feedback",
    "tags": [
      "cursor",
      "magnetic",
      "spring",
      "hover",
      "focus-visible",
      "pointer",
      "snap",
      "morph"
    ],
    "tech": [
      "CSS",
      "JS",
      "SVG"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#62e3ff",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/ring-cursor/index.html",
    "thumbnail": "https://vividsites.app/api/component/ring-cursor/thumb.jpg",
    "video": "https://vividsites.app/api/component/ring-cursor/thumb.mp4",
    "page": "https://vividsites.app/c/ring-cursor"
  }
}