{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-ligature-melt",
  "title": "Text Ligature Melt",
  "description": "Headline whose glyphs liquefy near the cursor: an SVG gooey filter fuses neighbors into temporary ligatures that spring apart on leave, with a faint field drifting the melt on its own at rest.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/text-ligature-melt/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// LigatureMelt — a headline whose glyphs liquefy near the cursor. An SVG gooey\n// filter (feGaussianBlur + alpha-threshold feColorMatrix) fuses overlapping\n// glyph edges into temporary ligature necks; glyphs inside a Gaussian field\n// swell and pull toward the cursor while out-of-field glyphs compress so the\n// total line width stays constant (both ends pinned). Leaving snaps every\n// ligature apart on an underdamped spring — visible surface-tension overshoot.\n// At rest a faint synthetic field sweeps the line on its own (half the\n// amplitude of a real hover) so the melt is visibly ambient, not something\n// that only exists on hover; the goo blur itself ramps off that same field\n// (near-zero away from it, up to `blur` inside it) instead of sitting at a\n// constant blobby value, so idle text reads crisp except where it's melting.\n// Real DOM text throughout: selectable, SEO-safe, the filter is visual only.\n// Direct-DOM rAF loop — no React state on the hot path, pauses offscreen.\n// ---------------------------------------------------------------------------\nexport function LigatureMelt({\n  text = \"SURFACE TENSION\",\n  sigma = 70,\n  swell = 0.35,\n  pull = 6,\n  blur = 6,\n  className = \"\",\n}: {\n  /** headline text — rendered as real, selectable DOM glyphs */\n  text?: string;\n  /** gaussian falloff radius of the melt field in px */\n  sigma?: number;\n  /** max extra scale at zero distance (0.35 → 1.35x) */\n  swell?: number;\n  /** max translation toward the cursor in px — creates the overlap that goos */\n  pull?: number;\n  /** feGaussianBlur stdDeviation at full field strength — ramps down to near-zero at rest */\n  blur?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const lineRef = useRef<HTMLSpanElement>(null);\n  const blurRef = useRef<SVGFEGaussianBlurElement>(null);\n  const reactId = useId();\n  const filterId = `lm-goo-${reactId.replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const glyphs = Array.from(text);\n  const idleBlur = Math.min(0.6, blur * 0.1);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const line = lineRef.current;\n    if (!root || !line) return;\n\n    // reduced motion: crisp static text — filter off, no listeners, no loop\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n      line.style.filter = \"none\";\n      return () => {\n        line.style.filter = \"\";\n      };\n    }\n\n    const spans = Array.from(line.children) as HTMLElement[];\n    const n = spans.length;\n    if (n === 0) return;\n\n    // layout metrics — offset* ignores transforms, so remeasuring is safe live\n    const baseC = new Float64Array(n); // glyph center, relative to root\n    const baseW = new Float64Array(n);\n    const gaps = new Float64Array(n); // kerning/tracking gap after glyph i\n    let lineLeft = 0;\n    const measure = () => {\n      for (let i = 0; i < n; i++) {\n        baseW[i] = spans[i].offsetWidth;\n        baseC[i] = spans[i].offsetLeft + spans[i].offsetWidth / 2;\n      }\n      for (let i = 0; i < n - 1; i++) {\n        gaps[i] = spans[i + 1].offsetLeft - (spans[i].offsetLeft + baseW[i]);\n      }\n      gaps[n - 1] = 0;\n      lineLeft = spans[0].offsetLeft;\n    };\n    measure();\n\n    // hot-path state — plain arrays only, the rAF loop is the sole DOM writer\n    const g = new Float64Array(n);\n    const dx = new Float64Array(n);\n    const sx = new Float64Array(n).fill(1);\n    const sy = new Float64Array(n).fill(1);\n    const vDx = new Float64Array(n);\n    const vSx = new Float64Array(n);\n    const vSy = new Float64Array(n);\n    const tDx = new Float64Array(n);\n    const tSx = new Float64Array(n).fill(1);\n    const tSy = new Float64Array(n).fill(1);\n\n    let cursorX = 0;\n    let hovered = false;\n    let releasing = false;\n    let visible = true;\n    let raf = 0;\n    let last = 0;\n    let blurCur = idleBlur;\n    let ambientElapsed = 0;\n\n    const K = 170; // spring stiffness s^-2\n    const C = 2 * 0.55 * Math.sqrt(K); // zeta 0.55 — one visible rebound\n    const twoSigma2 = 2 * sigma * sigma;\n    // ambient: a synthetic field sweeps the line on its own at rest, at half\n    // the amplitude of a real hover, so the melt reads as the default look\n    // rather than something only a pointer can trigger\n    const AMBIENT_SWELL = swell * 0.5;\n    const AMBIENT_PULL = pull * 0.5;\n    const AMBIENT_PERIOD = 5200; // ms per back-and-forth sweep\n    // the field's peak is ~1 almost continuously while ambient sweeps past\n    // *some* glyph, so it can't drive the blur ramp at full strength without\n    // recreating the old \"always blobby\" look — only hover earns full goo\n    const AMBIENT_BLUR_SCALE = 0.22;\n\n    const apply = () => {\n      for (let i = 0; i < n; i++) {\n        spans[i].style.transform =\n          `translateX(${dx[i].toFixed(3)}px) scale(${sx[i].toFixed(4)}, ${sy[i].toFixed(4)})`;\n      }\n    };\n\n    // writes tDx/tSx/tSy for a given field center + swell/pull amplitude,\n    // returns the peak field value (drives the blur ramp)\n    const computeTargets = (cx: number, swellAmt: number, pullAmt: number) => {\n      // pass 1: gaussian field, swell scale, total induced width surplus\n      let excess = 0;\n      let wSum = 0;\n      let maxG = 0;\n      for (let i = 0; i < n; i++) {\n        const d = cx - baseC[i];\n        const gi = Math.exp(-(d * d) / twoSigma2);\n        g[i] = gi;\n        if (gi > maxG) maxG = gi;\n        const s = 1 + swellAmt * gi;\n        tSy[i] = s;\n        excess += baseW[i] * (s - 1);\n        wSum += baseW[i] * (1 - gi);\n      }\n      // pass 2: width conservation — the surplus is paid back by compressing\n      // out-of-field glyphs (weighted by 1 - g), then a cumulative re-layout\n      // keeps the line contiguous with both ends pinned. The field pull then\n      // deliberately breaks contiguity near the field so edges overlap and goo.\n      let x = lineLeft;\n      for (let i = 0; i < n; i++) {\n        const w =\n          baseW[i] * tSy[i] -\n          (wSum > 1e-3 ? (excess * baseW[i] * (1 - g[i])) / wSum : 0);\n        tSx[i] = w / Math.max(1e-3, baseW[i]);\n        const d = cx - baseC[i];\n        tDx[i] = x + w / 2 - baseC[i] + pullAmt * g[i] * Math.tanh(d / 24);\n        x += w + gaps[i];\n      }\n      return maxG;\n    };\n\n    // framerate-normalized lerp toward the current targets; velocity is\n    // tracked so a leave mid-approach hands momentum to the release spring\n    const approach = (dt: number, rate: number) => {\n      const a = 1 - Math.pow(rate, dt * 60);\n      for (let i = 0; i < n; i++) {\n        const ndx = dx[i] + (tDx[i] - dx[i]) * a;\n        const nsx = sx[i] + (tSx[i] - sx[i]) * a;\n        const nsy = sy[i] + (tSy[i] - sy[i]) * a;\n        vDx[i] = (ndx - dx[i]) / dt;\n        vSx[i] = (nsx - sx[i]) / dt;\n        vSy[i] = (nsy - sy[i]) / dt;\n        dx[i] = ndx;\n        sx[i] = nsx;\n        sy[i] = nsy;\n      }\n    };\n\n    // ambient field position: a slow cosine sweep between the line's ends\n    const ambientCenter = (elapsedMs: number) => {\n      const span = baseC[n - 1] - baseC[0] || 1;\n      const t = (elapsedMs % AMBIENT_PERIOD) / AMBIENT_PERIOD;\n      const f = (1 - Math.cos(t * Math.PI * 2)) / 2; // 0 -> 1 -> 0\n      return baseC[0] + span * f;\n    };\n\n    const loop = (now: number) => {\n      const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);\n      last = now;\n      let fieldPeak = 0;\n\n      if (hovered) {\n        fieldPeak = computeTargets(cursorX, swell, pull);\n        approach(dt, 0.85); // lerp ~0.15/frame — responsive to the real cursor\n      } else if (releasing) {\n        // release: every span snaps back on an underdamped spring — the\n        // ligatures pinch apart with surface-tension overshoot\n        let settled = true;\n        let maxDeviation = 0;\n        for (let i = 0; i < n; i++) {\n          let v = vDx[i];\n          let p = dx[i];\n          v += (-K * p - C * v) * dt;\n          p += v * dt;\n          vDx[i] = v;\n          dx[i] = p;\n\n          v = vSx[i];\n          let s = sx[i];\n          v += (-K * (s - 1) - C * v) * dt;\n          s += v * dt;\n          vSx[i] = v;\n          sx[i] = s;\n\n          v = vSy[i];\n          s = sy[i];\n          v += (-K * (s - 1) - C * v) * dt;\n          s += v * dt;\n          vSy[i] = v;\n          sy[i] = s;\n\n          maxDeviation = Math.max(\n            maxDeviation,\n            Math.abs(dx[i]) / Math.max(1, pull),\n            Math.abs(sy[i] - 1) / Math.max(1e-3, swell)\n          );\n          if (\n            Math.abs(dx[i]) > 0.02 ||\n            Math.abs(vDx[i]) > 0.5 ||\n            Math.abs(sx[i] - 1) > 0.002 ||\n            Math.abs(vSx[i]) > 0.05 ||\n            Math.abs(sy[i] - 1) > 0.002 ||\n            Math.abs(vSy[i]) > 0.05\n          ) {\n            settled = false;\n          }\n        }\n        fieldPeak = Math.min(1, maxDeviation);\n        if (settled) {\n          for (let i = 0; i < n; i++) {\n            dx[i] = 0;\n            sx[i] = 1;\n            sy[i] = 1;\n            vDx[i] = 0;\n            vSx[i] = 0;\n            vSy[i] = 0;\n          }\n          releasing = false;\n          ambientElapsed = 0;\n        }\n      } else {\n        // ambient: no pointer involved — a faint field drifts on its own so\n        // the melt is the resting look, not a hover-only trick\n        ambientElapsed += dt * 1000;\n        const ax = ambientCenter(ambientElapsed);\n        fieldPeak = computeTargets(ax, AMBIENT_SWELL, AMBIENT_PULL) * AMBIENT_BLUR_SCALE;\n        approach(dt, 0.965); // much slower lerp — a gentle breathing motion\n      }\n\n      apply();\n\n      // goo blur ramps off the field itself — near-zero away from any\n      // activity, up to `blur` at the field's peak — instead of sitting at a\n      // constant blobby value the whole time. Ambient's peak is scaled down\n      // (see AMBIENT_BLUR_SCALE) since the sweep sits near *some* glyph\n      // almost continuously — full-strength blur there would just recreate\n      // the old always-blobby look; only a real hover earns full goo.\n      const targetBlur = idleBlur + (blur - idleBlur) * fieldPeak;\n      blurCur += (targetBlur - blurCur) * Math.min(1, dt * 6);\n      blurRef.current?.setAttribute(\"stdDeviation\", blurCur.toFixed(3));\n\n      raf = visible ? requestAnimationFrame(loop) : 0;\n    };\n\n    const wake = () => {\n      if (!raf && visible) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    const onMove = (e: PointerEvent) => {\n      cursorX = e.clientX - root.getBoundingClientRect().left;\n      hovered = true;\n      releasing = false;\n      wake();\n    };\n    const onLeave = () => {\n      if (!hovered) return;\n      hovered = false;\n      releasing = true;\n      wake();\n    };\n    // keyboard/focus parity: a tab-focused line melts centered on itself,\n    // same as a cursor parked mid-line — blur triggers the same release spring\n    const onFocus = () => {\n      cursorX = (baseC[0] + baseC[n - 1]) / 2;\n      hovered = true;\n      releasing = false;\n      wake();\n    };\n\n    root.addEventListener(\"pointermove\", onMove);\n    root.addEventListener(\"pointerdown\", onMove);\n    root.addEventListener(\"pointerleave\", onLeave);\n    root.addEventListener(\"pointerup\", onLeave);\n    root.addEventListener(\"pointercancel\", onLeave);\n    root.addEventListener(\"focus\", onFocus);\n    root.addEventListener(\"blur\", onLeave);\n    const ro = new ResizeObserver(measure);\n    ro.observe(line);\n    // ambient motion never \"settles\", so sleeping means pausing offscreen\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible) wake();\n      else if (raf) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        last = 0;\n      }\n    });\n    io.observe(root);\n\n    wake(); // ambient drift is the default look — start immediately\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerdown\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n      root.removeEventListener(\"pointerup\", onLeave);\n      root.removeEventListener(\"pointercancel\", onLeave);\n      root.removeEventListener(\"focus\", onFocus);\n      root.removeEventListener(\"blur\", onLeave);\n      for (const el of spans) el.style.transform = \"\";\n    };\n  }, [text, sigma, swell, pull, blur, idleBlur]);\n\n  return (\n    <span\n      ref={rootRef}\n      role=\"button\"\n      tabIndex={0}\n      className={`relative inline-block rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-accent ${className}`}\n    >\n      {/* blur + alpha threshold: overlapping glyph edges fuse into necks */}\n      <svg aria-hidden focusable=\"false\" className=\"absolute h-0 w-0\">\n        <defs>\n          <filter\n            id={filterId}\n            x=\"-30%\"\n            y=\"-60%\"\n            width=\"160%\"\n            height=\"220%\"\n            colorInterpolationFilters=\"sRGB\"\n          >\n            <feGaussianBlur\n              ref={blurRef}\n              in=\"SourceGraphic\"\n              stdDeviation={idleBlur}\n              result=\"b\"\n            />\n            <feColorMatrix\n              in=\"b\"\n              type=\"matrix\"\n              values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 19 -9\"\n            />\n          </filter>\n        </defs>\n      </svg>\n      <span\n        ref={lineRef}\n        role=\"text\"\n        aria-label={text}\n        className=\"inline-block whitespace-pre\"\n        style={{ filter: `url(#${filterId})` }}\n      >\n        {glyphs.map((c, i) => (\n          <span key={i} aria-hidden className=\"inline-block will-change-transform\">\n            {c}\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/text-ligature-melt.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-accent": "#006bff"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "text",
      "cursor",
      "svg-filter",
      "goo",
      "micro-interaction"
    ],
    "instruction": "A headline where characters near the cursor liquefy into temporary ligatures. One span per glyph inside a container carrying CSS filter: url(#goo); the inline SVG filter is feGaussianBlur into an feColorMatrix with alpha row 0 0 0 19 -9 (blur + threshold), so overlapping glyph edges fuse into metaball necks. Text stays real DOM — selectable and SEO-safe, the filter is visual only. A Gaussian field (sigma 70px) around a field center scales affected glyphs to 1 + swell*g and translates them toward the field by up to `pull` px so edges overlap and goo; total line width stays constant by redistributing the induced width surplus as compression across out-of-field spans each frame with line start/end pinned. At rest — no pointer involved — a synthetic field sweeps back and forth across the line on its own at half the hover amplitude, so the melt is the default look, not a hover-only trick; a real pointer takes over the field immediately. The feGaussianBlur stdDeviation itself ramps off the same field's peak value (near-zero away from any activity, up to the `blur` prop at the field's peak) instead of sitting at a constant blobby value, so idle text reads crisp except where it's actually melting. Per-span state (scale x/y, dx) lives in plain arrays written as transforms by a direct-DOM rAF loop — no React state on the hot path. Approach eases via lerp (~0.15/frame on hover, ~0.035/frame ambient); on pointer leave (including pointerup/pointercancel) every span snaps back on an underdamped spring (k = 170 s^-2, zeta = 0.55) with visible surface-tension overshoot as the ligatures pinch apart, then ambient drift resumes. The root carries role=\"button\" and tabIndex=0 so the mechanic has a keyboard/focus equivalent too: tabbing in melts the line centered on itself (same field math as a parked cursor) with a token-colored focus-visible ring, and blur releases it on the same spring. The loop never fully sleeps — since the ambient field never settles, an IntersectionObserver pauses it offscreen instead. Spans are measured once via offsetLeft/offsetWidth and remeasured on ResizeObserver. Under prefers-reduced-motion the filter is removed and the text renders static and crisp with no listeners, no ambient loop. Zero dependencies."
  },
  "type": "registry:ui"
}