{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "weight-hover",
  "type": "registry:block",
  "title": "Weight Hover",
  "description": "Variable-font lettering that swells under the cursor: weight, width and colour follow distance, and letters lean into fast swipes, each on its own spring so the wake settles behind. Works with touch.",
  "docs": "Weight Hover was added to public/vividsites/weight-hover.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/weight-hover",
  "categories": [
    "ui",
    "text-type"
  ],
  "files": [
    {
      "path": "components/vividsites/weight-hover.html",
      "type": "registry:file",
      "target": "public/vividsites/weight-hover.html",
      "content": "<!--\n  vs-weight-hover: variable-font lettering that swells under the cursor. Each letter's weight, width,\n  colour and lean follow its distance to the pointer and the pointer's speed, and every letter rides\n  its own spring, so a fast swipe leaves a wake that leans after it and settles behind it.\n  Works with touch (drag a finger across it).\n\n  Markup: data-vs-weight-hover on any element with plain text (use <br> for fixed lines, which then\n  never re-wrap as letters swell), set in a variable font with wght and wdth axes (Archivo below).\n  Distances are measured from where letters sit at rest, so the swelling never feeds back into itself.\n    data-vs-weight-hover=\"press\"   weight rises while width falls, so lines keep their length\n    (default \"swell\")              weight and width rise together\n  On first view the letters sweep once from left to right, then wait for the pointer.\n  All variables are read live: change them in CSS or inline and the effect follows.\n\n  Variables:\n    --vs-weight-hover-radius     reach of the cursor, in em of the text      1.6\n    --vs-weight-hover-wght-min / -wght-max                                   120 / 900\n    --vs-weight-hover-wdth-min / -wdth-max                                   62 / 125\n    --vs-weight-hover-rest       opacity of letters far from the cursor     .5\n    --vs-weight-hover-ink        colour at rest                             #111\n    --vs-weight-hover-accent     colour at full swell (= ink for none)      #2340ff\n    --vs-weight-hover-lean       how far letters lean with pointer speed, 0–1   .7\n    --vs-weight-hover-tension / -friction      the per-letter spring (190 / 20)\n  prefers-reduced-motion: letters sit at the middle of the range; no motion.\n-->\n<style>\n  .vs-weight-hover {\n    --vs-weight-hover-radius: 1.6;\n    --vs-weight-hover-wght-min: 120;\n    --vs-weight-hover-wght-max: 900;\n    --vs-weight-hover-wdth-min: 62;\n    --vs-weight-hover-wdth-max: 125;\n    --vs-weight-hover-rest: .5;\n    --vs-weight-hover-ink: #111;\n    --vs-weight-hover-accent: #2340ff;\n    --vs-weight-hover-lean: .7;\n    --vs-weight-hover-tension: 190;\n    --vs-weight-hover-friction: 20;\n    color: var(--vs-weight-hover-ink);\n    font-variation-settings: \"wght\" var(--vs-weight-hover-wght-min), \"wdth\" var(--vs-weight-hover-wdth-min);\n    touch-action: pan-y;\n    -webkit-user-select: none; user-select: none;\n  }\n  .vs-weight-hover-w { display: inline-block; white-space: nowrap; }\n  .vs-weight-hover-c { display: inline-block; transform-origin: 50% 85%; }\n  .vs-weight-hover-line { display: block; white-space: nowrap; }\n  .vs-weight-hover-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n</style>\n<script>\n(() => {\n  if (window.VsWeightHover) return;\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const all = new Set();\n  let px = -1e9, py = -1e9, pointerSeen = false, vx = 0, lastMove = 0;\n\n  const readCfg = (rec) => {\n    const cs = getComputedStyle(rec.el), n = (k, fb) => { const v = parseFloat(cs.getPropertyValue(k)); return isNaN(v) ? fb : v; };\n    rec.cfg = {\n      r: n('--vs-weight-hover-radius', 1.6),\n      w0: n('--vs-weight-hover-wght-min', 120), w1: n('--vs-weight-hover-wght-max', 900),\n      d0: n('--vs-weight-hover-wdth-min', 62), d1: n('--vs-weight-hover-wdth-max', 125),\n      rest: n('--vs-weight-hover-rest', .5), lean: n('--vs-weight-hover-lean', .7),\n      k: n('--vs-weight-hover-tension', 190), c: n('--vs-weight-hover-friction', 20),\n      press: rec.el.dataset.vsWeightHover === 'press',\n    };\n  };\n\n  function mount(el) {\n    // split into letters, words kept whole; a <br> starts a new line that never re-wraps\n    const lines = [...el.childNodes].map((n) => (n.nodeName === 'BR' ? '\\n' : n.textContent.replace(/\\s+/g, ' '))).join('')\n      .split('\\n').map((l) => l.trim()).filter(Boolean);\n    const text = lines.join(' ');\n    el.textContent = '';\n    const sr = document.createElement('span'); sr.className = 'vs-weight-hover-sr'; sr.textContent = text;\n    const vis = document.createElement('span'); vis.setAttribute('aria-hidden', 'true');\n    const cells = [];\n    lines.forEach((line) => {\n      const L = document.createElement('span'); L.className = lines.length > 1 ? 'vs-weight-hover-line' : '';\n      line.split(' ').forEach((word, wi) => {\n        if (wi) L.appendChild(document.createTextNode(' '));\n        const w = document.createElement('span'); w.className = 'vs-weight-hover-w';\n        for (const ch of word) {\n          const c = document.createElement('span'); c.className = 'vs-weight-hover-c'; c.textContent = ch;\n          w.appendChild(c); cells.push({ node: c, x: 0, v: 0, s: 0, sv: 0, cx: 0, cy: 0 });\n        }\n        L.appendChild(w);\n      });\n      vis.appendChild(L);\n    });\n    el.append(sr, vis);\n    const rec = { el, cells, visible: false, em: 16, intro: -1 };\n    readCfg(rec);\n    all.add(rec);\n    // settings are live: any style / class / data change re-reads the variables\n    new MutationObserver(() => { readCfg(rec); measure(rec); kick(); }).observe(el, { attributes: true, attributeFilter: ['style', 'class', 'data-vs-weight-hover'] });\n    el.vsWeightHover = { cells, refresh: () => { readCfg(rec); measure(rec); kick(); } };\n    cells.forEach((c) => paint(rec, c, reduced ? .5 : 0, 0));\n    measure(rec);\n    new IntersectionObserver(([e]) => {\n      rec.visible = e.isIntersecting;\n      if (rec.visible && rec.intro < 0 && !reduced) rec.intro = performance.now();\n      kick();\n    }, { threshold: .15 }).observe(el);\n    return rec;\n  }\n\n  // letter centres at REST, relative to the element: the swell never moves its own targets\n  function measure(rec) {\n    const saved = rec.cells.map((c) => [c.node.style.fontVariationSettings, c.node.style.transform]);\n    rec.cells.forEach((c) => { c.node.style.fontVariationSettings = ''; c.node.style.transform = ''; });\n    const b = rec.el.getBoundingClientRect();\n    rec.cells.forEach((c) => { const r = c.node.getBoundingClientRect(); c.cx = r.left + r.width / 2 - b.left; c.cy = r.top + r.height / 2 - b.top; });\n    rec.cells.forEach((c, i) => { c.node.style.fontVariationSettings = saved[i][0]; c.node.style.transform = saved[i][1]; });\n    rec.em = parseFloat(getComputedStyle(rec.el).fontSize) || 16;\n  }\n\n  function paint(rec, c, t, lean) {\n    const { w0, w1, d0, d1, rest, press } = rec.cfg;\n    const e = Math.max(0, Math.min(1, t));\n    const wg = w0 + (w1 - w0) * e;\n    const wd = press ? d1 - (d1 - d0) * e * .9 : d0 + (d1 - d0) * e;\n    c.node.style.fontVariationSettings = `\"wght\" ${wg.toFixed(0)}, \"wdth\" ${wd.toFixed(1)}`;\n    c.node.style.opacity = (rest + (1 - rest) * Math.min(1, e * 1.4)).toFixed(3);\n    c.node.style.color = e < .01 ? '' : `color-mix(in oklab, var(--vs-weight-hover-accent) ${(Math.min(1, e * 1.15) * 100).toFixed(1)}%, var(--vs-weight-hover-ink))`;\n    c.node.style.transform = Math.abs(lean) < .05 ? '' : `skewX(${lean.toFixed(2)}deg)`;\n  }\n\n  let raf = 0, last = 0;\n  function frame(now) {\n    const dt = Math.min(64, now - (last || now)) / 1000; last = now;\n    if (now - lastMove > 60) vx *= Math.pow(.02, dt);          // pointer stopped: its speed decays\n    let busy = Math.abs(vx) > 5;\n    all.forEach((rec) => {\n      if (!rec.visible) return;\n      const b = rec.el.getBoundingClientRect();\n      const R = rec.cfg.r * rec.em;\n      // the entrance: a single pass of the \"cursor\" across the letters, before the real one arrives\n      let lx = px - b.left, ly = py - b.top, introOn = false, v = vx;\n      if (rec.intro > 0 && !pointerSeen) {\n        const p = (now - rec.intro) / 2200;\n        if (p < 1) { introOn = true; const e = p < .5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2; lx = -R + (b.width + 2 * R) * e; ly = b.height / 2; v = 900 * Math.sin(p * Math.PI); busy = true; }\n        else rec.intro = 0;\n      }\n      const { k, c } = rec.cfg;\n      const leanGoal = -Math.max(-1, Math.min(1, v / 2400)) * 14 * rec.cfg.lean;   // degrees, leaning into the motion\n      for (const cell of rec.cells) {\n        const dx = (cell.cx - lx), dy = (cell.cy - ly) * 1.6;      // vertical falloff is tighter than horizontal\n        const d = Math.sqrt(dx * dx + dy * dy) / R;\n        const near = introOn || pointerSeen ? Math.exp(-d * d * 1.8) : 0;\n        const sGoal = leanGoal * near;\n        for (let h = 0; h < dt; h += 1 / 120) {\n          const s = Math.min(1 / 120, dt - h);\n          cell.v += (-k * (cell.x - near) - c * cell.v) * s; cell.x += cell.v * s;\n          cell.sv += (-k * .7 * (cell.s - sGoal) - c * .9 * cell.sv) * s; cell.s += cell.sv * s;\n        }\n        if (Math.abs(cell.v) > .0005 || Math.abs(cell.x - near) > .0005 || Math.abs(cell.s - sGoal) > .02 || Math.abs(cell.sv) > .02) busy = true;\n        paint(rec, cell, cell.x, cell.s);\n      }\n    });\n    raf = busy ? requestAnimationFrame(frame) : 0;\n  }\n  const kick = () => { if (!raf && !reduced) { last = 0; raf = requestAnimationFrame(frame); } };\n\n  addEventListener('pointermove', (e) => {\n    const t = e.timeStamp, dt = t - lastMove;\n    if (pointerSeen && dt > 0 && dt < 100) vx += ((e.clientX - px) / (dt / 1000) - vx) * .35;   // smoothed px/s\n    px = e.clientX; py = e.clientY; lastMove = t; pointerSeen = true; kick();\n  }, { passive: true });\n  addEventListener('pointerdown', (e) => { px = e.clientX; py = e.clientY; pointerSeen = true; kick(); }, { passive: true });\n  const away = () => { px = py = -1e9; kick(); };\n  document.addEventListener('pointerleave', away);\n  addEventListener('pointerup', (e) => { if (e.pointerType === 'touch') away(); });\n  addEventListener('pointercancel', away);\n  addEventListener('scroll', kick, { passive: true });\n  let rt = 0;\n  addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => all.forEach(measure), 120); });\n\n  const ready = Promise.race([document.fonts ? document.fonts.ready : Promise.resolve(), new Promise((r) => setTimeout(r, 2500))]);\n  window.VsWeightHover = { mount: (el) => ready.then(() => mount(el)) };\n  const init = () => ready.then(() => document.querySelectorAll('[data-vs-weight-hover]').forEach(mount));\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n})();\n</script>\n\n<!-- usage -->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link href=\"https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&display=swap\" rel=\"stylesheet\">\n<div style=\"background:#f2f0ea;padding:12vh 5vw;text-align:center\">\n  <h2 class=\"vs-weight-hover\" data-vs-weight-hover\n      style=\"margin:0;font:clamp(3rem,11vw,10rem)/.86 'Archivo',sans-serif;letter-spacing:-.02em;text-transform:uppercase\">\n    Pressure<br>makes form\n  </h2>\n</div>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Text & type",
    "tags": [
      "variable font",
      "hover",
      "cursor",
      "typography",
      "spring",
      "kinetic type"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#2340ff",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/weight-hover/index.html",
    "thumbnail": "https://vividsites.app/api/component/weight-hover/thumb.jpg",
    "video": "https://vividsites.app/api/component/weight-hover/thumb.mp4",
    "page": "https://vividsites.app/c/weight-hover"
  }
}