{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-variable-weight",
  "title": "Text Variable Weight",
  "description": "Letters morph variable-font weight by cursor proximity. Pure typography interaction.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/text-variable-weight/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// Per-letter variable-font weight driven by cursor proximity.\n// Geist Sans is a variable font, so style.fontWeight maps straight to wght.\n// Direct-DOM rAF loop — no React state on the hot path.\nexport function DynamicWeightText({\n  text,\n  minWeight = 300,\n  maxWeight = 800,\n  sigma = 90,\n  className = \"\",\n}: {\n  /** the rendered text */\n  text: string;\n  /** font-weight furthest from the cursor */\n  minWeight?: number;\n  /** font-weight closest to the cursor */\n  maxWeight?: number;\n  /** gaussian falloff radius in px */\n  sigma?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const rootRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n\n    const letters = Array.from(root.children) as HTMLElement[];\n    const weights = letters.map(() => minWeight);\n    let cursorX: number | null = null;\n    let raf = 0;\n\n    const loop = () => {\n      let settled = true;\n      letters.forEach((el, i) => {\n        let target = minWeight;\n        if (cursorX !== null) {\n          const r = el.getBoundingClientRect();\n          const d = cursorX - (r.left + r.width / 2);\n          target = minWeight + (maxWeight - minWeight) * Math.exp(-(d * d) / (2 * sigma * sigma));\n        }\n        weights[i] += (target - weights[i]) * 0.16;\n        if (Math.abs(target - weights[i]) > 0.5) settled = false;\n        el.style.fontWeight = String(Math.round(weights[i]));\n      });\n      // Park once weights converge — target only changes when cursorX moves\n      // (or the pointer leaves), both of which call wake() below, so it's\n      // safe to stop re-scheduling while the pointer sits still.\n      raf = settled ? 0 : requestAnimationFrame(loop);\n    };\n    const wake = () => {\n      if (!raf) raf = requestAnimationFrame(loop);\n    };\n\n    const onMove = (e: PointerEvent) => {\n      cursorX = e.clientX;\n      wake();\n    };\n    const onLeave = () => {\n      cursorX = null;\n      wake();\n    };\n\n    root.addEventListener(\"pointermove\", onMove);\n    root.addEventListener(\"pointerleave\", onLeave);\n    return () => {\n      cancelAnimationFrame(raf);\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [text, minWeight, maxWeight, sigma]);\n\n  return (\n    <span ref={rootRef} aria-label={text} role=\"text\" className={className}>\n      {Array.from(text).map((c, i) => (\n        <span key={i} aria-hidden style={{ fontWeight: minWeight }}>\n          {c === \" \" ? \" \" : c}\n        </span>\n      ))}\n    </span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/text-variable-weight.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "text",
      "typography",
      "cursor",
      "variable-font"
    ],
    "instruction": "A headline whose individual letters morph between a light and a heavy variable-font weight based on horizontal cursor proximity with a Gaussian falloff, easing smoothly as the pointer moves and settling back when it leaves. Pure typography — no color, no particles. Direct-DOM rAF loop, real text in aria-label, static under prefers-reduced-motion."
  },
  "type": "registry:ui"
}