{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "blend-cursor",
  "type": "registry:block",
  "title": "Blend Cursor",
  "description": "A difference-blend cursor on a real spring that stretches along its own velocity, swells into a disc that inverts links and buttons while leaning toward their centre, and turns into a line-height caret over text. Fine pointers only: touch keeps the native cursor, and the loop sleeps once it settles.",
  "docs": "Blend Cursor was added to public/vividsites/blend-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/blend-cursor",
  "categories": [
    "ui",
    "loaders-cursors-feedback"
  ],
  "files": [
    {
      "path": "components/vividsites/blend-cursor.html",
      "type": "registry:file",
      "target": "public/vividsites/blend-cursor.html",
      "content": "<!--\n  vs-blend-cursor: a custom cursor that inverts whatever it passes over (mix-blend-mode: difference).\n  A small dot rides a real spring (semi-implicit Euler, stiffness + damping) and stretches along its\n  own velocity. Over links and buttons it swells into a large disc that inverts the letters beneath\n  and leans toward the target's centre (magnetic pull). Over text zones it becomes a caret bar the\n  height of the line, snapped to the line under the pointer. Pressing shrinks it.\n  Fine pointers only: on touch / coarse pointers it never appears and the native cursor stays.\n  It hides when the pointer leaves the window, and its rAF loop stops as soon as it settles.\n\n  Markup: paste the <div class=\"vs-blend-cursor\"> once, anywhere in <body>.\n  Targets (grow into the disc): a[href], button, [role=button], label[for], summary, select,\n    and anything with [data-vs-blend-cursor].\n    data-vs-blend-cursor=\"text\"   caret bar, height of the element's line-height\n    data-vs-blend-cursor=\"none\"   hide the custom cursor here (native cursor shows, e.g. over video)\n    data-vs-blend-cursor-size=\"140\"   per-element disc diameter in px (overrides --grow)\n  Disabled targets ([disabled], [aria-disabled=\"true\"]) keep the small dot, dimmed.\n\n  Variables (read live: change them in CSS or inline on :root and the cursor follows):\n    --vs-blend-cursor-size        dot diameter                                14px\n    --vs-blend-cursor-grow        disc diameter over targets                  104px\n    --vs-blend-cursor-stiffness   follow spring stiffness; lower = more lag   420\n    --vs-blend-cursor-damping     damping ratio, 1 = no overshoot             .74\n    --vs-blend-cursor-pull        magnetic pull toward a target's centre, 0-1 .22\n    --vs-blend-cursor-stretch     squash-and-stretch with speed, 0-1          .5\n    --vs-blend-cursor-color       the cursor colour (white inverts exactly)   #fff\n  prefers-reduced-motion: the cursor tracks the pointer directly, no stretch or overshoot.\n  Note: difference blends with what is painted under it, so give html or body a background colour\n  (if neither has one, the script paints html white, the browser's default, so the cursor shows).\n-->\n<style>\n  :root {\n    --vs-blend-cursor-size: 14px;\n    --vs-blend-cursor-grow: 104px;\n    --vs-blend-cursor-stiffness: 420;\n    --vs-blend-cursor-damping: .74;\n    --vs-blend-cursor-pull: .22;\n    --vs-blend-cursor-stretch: .5;\n    --vs-blend-cursor-color: #fff;\n  }\n  .vs-blend-cursor {\n    position: fixed; left: 0; top: 0; width: 0; height: 0; z-index: 2147483000;\n    pointer-events: none; mix-blend-mode: difference; opacity: 0;\n    transition: opacity .22s cubic-bezier(.2, 0, 0, 1);\n    will-change: transform;\n  }\n  .vs-blend-cursor.is-on { opacity: 1; }\n  .vs-blend-cursor.is-dim { opacity: .45; }\n  .vs-blend-cursor-disc, .vs-blend-cursor-caret {\n    position: absolute; left: 0; top: 0; display: block;\n    background: var(--vs-blend-cursor-color); will-change: transform;\n    backface-visibility: hidden;\n  }\n  .vs-blend-cursor-disc {\n    width: var(--vs-blend-cursor-grow); height: var(--vs-blend-cursor-grow);\n    margin: calc(var(--vs-blend-cursor-grow) / -2) 0 0 calc(var(--vs-blend-cursor-grow) / -2);\n    border-radius: 50%;\n    transform: scale(0);\n  }\n  .vs-blend-cursor-caret {\n    width: 2px; height: 40px; margin: -20px 0 0 -1px; border-radius: 1px;\n    transform: scaleY(0);\n  }\n  @media (hover: hover) and (pointer: fine) {\n    html.vs-blend-cursor-live, html.vs-blend-cursor-live * { cursor: none !important; }\n    html.vs-blend-cursor-live [data-vs-blend-cursor=\"none\"],\n    html.vs-blend-cursor-live [data-vs-blend-cursor=\"none\"] * { cursor: auto !important; }\n  }\n  @media not all and (pointer: fine) { .vs-blend-cursor { display: none; } }\n</style>\n\n<div class=\"vs-blend-cursor\" aria-hidden=\"true\"><i class=\"vs-blend-cursor-disc\"></i><i class=\"vs-blend-cursor-caret\"></i></div>\n\n<script>\n(() => {\n  const el = document.querySelector('.vs-blend-cursor');\n  if (!el || el.__vs) return; el.__vs = 1;\n  const root = document.documentElement;\n  const disc = el.querySelector('.vs-blend-cursor-disc'), caret = el.querySelector('.vs-blend-cursor-caret');\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\"], label[for], summary, select, input[type=\"submit\"], input[type=\"button\"], [data-vs-blend-cursor]';\n\n  let cfg = {};\n  const read = () => {\n    const cs = getComputedStyle(el), n = (k, d) => { const v = parseFloat(cs.getPropertyValue(k)); return isNaN(v) ? d : v; };\n    cfg = {\n      size: n('--vs-blend-cursor-size', 14), grow: n('--vs-blend-cursor-grow', 88),\n      k: Math.max(20, n('--vs-blend-cursor-stiffness', 420)), z: Math.max(.1, n('--vs-blend-cursor-damping', .74)),\n      pull: n('--vs-blend-cursor-pull', .22), stretch: n('--vs-blend-cursor-stretch', .5),\n    };\n    kick();\n  };\n\n  // springs: position (x, y), disc scale (s, relative to --grow), caret (c, 0-1)\n  const P = { x: 0, y: 0, vx: 0, vy: 0 };\n  const S = { s: 0, v: 0 }, C = { s: 0, v: 0 };\n  let px = 0, py = 0, shown = false, pressed = false, live = false, raf = 0, last = 0;\n  let cur = null, mode = 'dot', rect = null, line = null, growPx = 0;\n\n  const measure = () => {\n    if (!cur) return;\n    rect = cur.getBoundingClientRect();\n    if (mode === 'text') {\n      const cs = getComputedStyle(cur), fs = parseFloat(cs.fontSize) || 16;\n      const lh = parseFloat(cs.lineHeight) || fs * 1.2;\n      line = { lh, top: rect.top + (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.borderTopWidth) || 0), n: 0 };\n      line.n = Math.max(1, Math.round((rect.height - (parseFloat(cs.paddingTop) || 0) - (parseFloat(cs.paddingBottom) || 0)) / lh));\n      const h = Math.round(Math.min(lh, fs * 1.25));\n      caret.style.height = h + 'px'; caret.style.marginTop = -h / 2 + 'px';\n    }\n  };\n  const resolve = (t) => {\n    const hit = t && t.closest ? t.closest(SEL) : null;\n    if (hit === cur) return;\n    cur = hit;\n    const kind = hit ? (hit.getAttribute('data-vs-blend-cursor') || '') : '';\n    const off = hit && (hit.disabled || hit.getAttribute('aria-disabled') === 'true');\n    mode = !hit ? 'dot' : kind === 'none' ? 'none' : kind === 'text' ? 'text' : off ? 'off' : 'grow';\n    growPx = hit && parseFloat(hit.getAttribute('data-vs-blend-cursor-size')) || 0;\n    el.classList.toggle('is-dim', mode === 'off');\n    measure(); kick();\n  };\n\n  const show = (on) => {\n    if (on === shown) return; shown = on;\n    el.classList.toggle('is-on', on);\n    if (on && !live) { live = true; root.classList.add('vs-blend-cursor-live'); }\n    kick();\n  };\n\n  const step = (o, key, target, k, z, dt) => {\n    const c = 2 * Math.sqrt(k) * z;\n    o['v' + key] += (k * (target - o[key]) - c * o['v' + key]) * dt;\n    o[key] += o['v' + key] * dt;\n  };\n  const spring1 = (o, target, k, z, dt) => { const c = 2 * Math.sqrt(k) * z; o.v += (k * (target - o.s) - c * o.v) * dt; o.s += o.v * dt; };\n\n  const frame = (now) => {\n    raf = 0;\n    let dt = Math.min(1 / 30, (now - last) / 1000 || 1 / 60); last = now;\n    const rm = reduce.matches;\n    // targets\n    let tx = px, ty = py, ts = cfg.size / cfg.grow, tc = 0;\n    if (mode === 'grow' && rect) {\n      const g = growPx || cfg.grow;\n      const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;\n      const lim = g * .32;\n      tx = px + Math.max(-lim, Math.min(lim, (cx - px) * cfg.pull));\n      ty = py + Math.max(-lim, Math.min(lim, (cy - py) * cfg.pull * 1.6));\n      ts = g / cfg.grow;\n    } else if (mode === 'text' && line) {\n      const i = Math.max(0, Math.min(line.n - 1, Math.floor((py - line.top) / line.lh)));\n      ty = line.top + (i + .5) * line.lh; ts = 0; tc = 1;\n    } else if (mode === 'none') { ts = 0; }\n    if (pressed) { ts *= mode === 'grow' ? .82 : .6; tc *= .8; }\n    if (!shown) { ts = 0; tc = 0; }\n\n    // integrate (two sub-steps keep stiff springs stable at 30 fps)\n    const k = rm ? 2000 : cfg.k, z = rm ? 1 : cfg.z;\n    for (let i = 0; i < 2; i++) {\n      const h = dt / 2;\n      if (rm) { P.x = tx; P.y = ty; P.vx = P.vy = 0; } else { step(P, 'x', tx, k, z, h); step(P, 'y', ty, k, z, h); }\n      spring1(S, ts, rm ? 900 : 340, rm ? 1 : .58, h);\n      spring1(C, tc, rm ? 900 : 520, rm ? 1 : .7, h);\n    }\n    const s = Math.max(0, S.s), c = Math.max(0, C.s);\n\n    // squash & stretch along the direction of travel, fading out as the disc grows\n    const speed = Math.hypot(P.vx, P.vy);\n    const big = Math.min(1, Math.max(0, (s * cfg.grow - cfg.size) / Math.max(1, cfg.grow - cfg.size)));\n    const st = rm ? 0 : Math.min(.6, speed / 2600) * cfg.stretch * (1 - .7 * big);\n    const a = Math.atan2(P.vy, P.vx);\n    el.style.transform = `translate3d(${P.x.toFixed(2)}px, ${P.y.toFixed(2)}px, 0)`;\n    disc.style.transform = `rotate(${a.toFixed(4)}rad) scale(${(s * (1 + st)).toFixed(4)}, ${(s * (1 - st * .55)).toFixed(4)})`;\n    caret.style.transform = `scale(${(1 + (1 - c) * 1.5).toFixed(3)}, ${c.toFixed(4)})`;\n\n    const settled = Math.abs(tx - P.x) < .05 && Math.abs(ty - P.y) < .05 && speed < .5 &&\n      Math.abs(ts - S.s) < .001 && Math.abs(S.v) < .01 && Math.abs(tc - C.s) < .001 && Math.abs(C.v) < .01;\n    if (!settled) 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') { hideTouch(); return; }\n    px = e.clientX; py = e.clientY;\n    if (!shown) { if (!live || !S.s) { P.x = px; P.y = py; P.vx = P.vy = 0; } show(true); }\n    resolve(e.target); kick();\n  };\n  const hideTouch = () => { show(false); live = false; root.classList.remove('vs-blend-cursor-live'); };\n  const onDown = (e) => { if (e.pointerType === 'touch') return hideTouch(); if (e.button === 0) { pressed = true; kick(); } };\n  const onUp = () => { if (pressed) { pressed = false; kick(); } };\n  const onScroll = () => { if (!shown) return; const t = document.elementFromPoint(px, py); cur = cur && t && cur.contains(t) ? cur : null; if (cur) measure(); else resolve(t); kick(); };\n\n  const enable = () => {\n    read();\n    // difference blends against the painted backdrop: a page that never sets a background has a\n    // transparent one, and a white cursor would vanish on it. Paint the browser default explicitly.\n    const clear = (e) => { const c = getComputedStyle(e).backgroundColor; return c === 'transparent' || /rgba\\(.*,\\s*0\\)$/.test(c); };\n    if (clear(root) && clear(document.body)) root.style.backgroundColor = '#fff';\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', onScroll, { passive: true, capture: true });\n    addEventListener('resize', () => { measure(); kick(); }, { passive: true });\n    addEventListener('blur', () => { pressed = false; show(false); });\n    root.addEventListener('mouseleave', () => { pressed = false; show(false); });\n    document.addEventListener('visibilitychange', () => { if (document.hidden) show(false); });\n    new MutationObserver(read).observe(root, { attributes: true, attributeFilter: ['style', 'class', 'data-stage'] });\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",
      "mix-blend-mode",
      "difference",
      "spring",
      "magnetic",
      "hover",
      "custom cursor",
      "mouse follower"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://api.fontshare.com/v2/css?f[]=general-sans@500,600&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#f3f2ef",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/blend-cursor/index.html",
    "thumbnail": "https://vividsites.app/api/component/blend-cursor/thumb.jpg",
    "video": "https://vividsites.app/api/component/blend-cursor/thumb.mp4",
    "page": "https://vividsites.app/c/blend-cursor"
  }
}