Text Ligature Melt

Text

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.

Install
npx shadcn add https://design.helpmarq.com/r/text-ligature-melt.json
Source
registry/core/text-ligature-melt/component.tsx
"use client";

import { useEffect, useId, useRef } from "react";

// ---------------------------------------------------------------------------
// LigatureMelt — a headline whose glyphs liquefy near the cursor. An SVG gooey
// filter (feGaussianBlur + alpha-threshold feColorMatrix) fuses overlapping
// glyph edges into temporary ligature necks; glyphs inside a Gaussian field
// swell and pull toward the cursor while out-of-field glyphs compress so the
// total line width stays constant (both ends pinned). Leaving snaps every
// ligature apart on an underdamped spring — visible surface-tension overshoot.
// At rest a faint synthetic field sweeps the line on its own (half the
// amplitude of a real hover) so the melt is visibly ambient, not something
// that only exists on hover; the goo blur itself ramps off that same field
// (near-zero away from it, up to `blur` inside it) instead of sitting at a
// constant blobby value, so idle text reads crisp except where it's melting.
// Real DOM text throughout: selectable, SEO-safe, the filter is visual only.
// Direct-DOM rAF loop — no React state on the hot path, pauses offscreen.
// ---------------------------------------------------------------------------
export function LigatureMelt({
  text = "SURFACE TENSION",
  sigma = 70,
  swell = 0.35,
  pull = 6,
  blur = 6,
  className = "",
}: {
  /** headline text — rendered as real, selectable DOM glyphs */
  text?: string;
  /** gaussian falloff radius of the melt field in px */
  sigma?: number;
  /** max extra scale at zero distance (0.35 → 1.35x) */
  swell?: number;
  /** max translation toward the cursor in px — creates the overlap that goos */
  pull?: number;
  /** feGaussianBlur stdDeviation at full field strength — ramps down to near-zero at rest */
  blur?: number;
  className?: string;
}) {
  const rootRef = useRef<HTMLSpanElement>(null);
  const lineRef = useRef<HTMLSpanElement>(null);
  const blurRef = useRef<SVGFEGaussianBlurElement>(null);
  const reactId = useId();
  const filterId = `lm-goo-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
  const glyphs = Array.from(text);
  const idleBlur = Math.min(0.6, blur * 0.1);

  useEffect(() => {
    const root = rootRef.current;
    const line = lineRef.current;
    if (!root || !line) return;

    // reduced motion: crisp static text — filter off, no listeners, no loop
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      line.style.filter = "none";
      return () => {
        line.style.filter = "";
      };
    }

    const spans = Array.from(line.children) as HTMLElement[];
    const n = spans.length;
    if (n === 0) return;

    // layout metrics — offset* ignores transforms, so remeasuring is safe live
    const baseC = new Float64Array(n); // glyph center, relative to root
    const baseW = new Float64Array(n);
    const gaps = new Float64Array(n); // kerning/tracking gap after glyph i
    let lineLeft = 0;
    const measure = () => {
      for (let i = 0; i < n; i++) {
        baseW[i] = spans[i].offsetWidth;
        baseC[i] = spans[i].offsetLeft + spans[i].offsetWidth / 2;
      }
      for (let i = 0; i < n - 1; i++) {
        gaps[i] = spans[i + 1].offsetLeft - (spans[i].offsetLeft + baseW[i]);
      }
      gaps[n - 1] = 0;
      lineLeft = spans[0].offsetLeft;
    };
    measure();

    // hot-path state — plain arrays only, the rAF loop is the sole DOM writer
    const g = new Float64Array(n);
    const dx = new Float64Array(n);
    const sx = new Float64Array(n).fill(1);
    const sy = new Float64Array(n).fill(1);
    const vDx = new Float64Array(n);
    const vSx = new Float64Array(n);
    const vSy = new Float64Array(n);
    const tDx = new Float64Array(n);
    const tSx = new Float64Array(n).fill(1);
    const tSy = new Float64Array(n).fill(1);

    let cursorX = 0;
    let hovered = false;
    let releasing = false;
    let visible = true;
    let raf = 0;
    let last = 0;
    let blurCur = idleBlur;
    let ambientElapsed = 0;

    const K = 170; // spring stiffness s^-2
    const C = 2 * 0.55 * Math.sqrt(K); // zeta 0.55 — one visible rebound
    const twoSigma2 = 2 * sigma * sigma;
    // ambient: a synthetic field sweeps the line on its own at rest, at half
    // the amplitude of a real hover, so the melt reads as the default look
    // rather than something only a pointer can trigger
    const AMBIENT_SWELL = swell * 0.5;
    const AMBIENT_PULL = pull * 0.5;
    const AMBIENT_PERIOD = 5200; // ms per back-and-forth sweep
    // the field's peak is ~1 almost continuously while ambient sweeps past
    // *some* glyph, so it can't drive the blur ramp at full strength without
    // recreating the old "always blobby" look — only hover earns full goo
    const AMBIENT_BLUR_SCALE = 0.22;

    const apply = () => {
      for (let i = 0; i < n; i++) {
        spans[i].style.transform =
          `translateX(${dx[i].toFixed(3)}px) scale(${sx[i].toFixed(4)}, ${sy[i].toFixed(4)})`;
      }
    };

    // writes tDx/tSx/tSy for a given field center + swell/pull amplitude,
    // returns the peak field value (drives the blur ramp)
    const computeTargets = (cx: number, swellAmt: number, pullAmt: number) => {
      // pass 1: gaussian field, swell scale, total induced width surplus
      let excess = 0;
      let wSum = 0;
      let maxG = 0;
      for (let i = 0; i < n; i++) {
        const d = cx - baseC[i];
        const gi = Math.exp(-(d * d) / twoSigma2);
        g[i] = gi;
        if (gi > maxG) maxG = gi;
        const s = 1 + swellAmt * gi;
        tSy[i] = s;
        excess += baseW[i] * (s - 1);
        wSum += baseW[i] * (1 - gi);
      }
      // pass 2: width conservation — the surplus is paid back by compressing
      // out-of-field glyphs (weighted by 1 - g), then a cumulative re-layout
      // keeps the line contiguous with both ends pinned. The field pull then
      // deliberately breaks contiguity near the field so edges overlap and goo.
      let x = lineLeft;
      for (let i = 0; i < n; i++) {
        const w =
          baseW[i] * tSy[i] -
          (wSum > 1e-3 ? (excess * baseW[i] * (1 - g[i])) / wSum : 0);
        tSx[i] = w / Math.max(1e-3, baseW[i]);
        const d = cx - baseC[i];
        tDx[i] = x + w / 2 - baseC[i] + pullAmt * g[i] * Math.tanh(d / 24);
        x += w + gaps[i];
      }
      return maxG;
    };

    // framerate-normalized lerp toward the current targets; velocity is
    // tracked so a leave mid-approach hands momentum to the release spring
    const approach = (dt: number, rate: number) => {
      const a = 1 - Math.pow(rate, dt * 60);
      for (let i = 0; i < n; i++) {
        const ndx = dx[i] + (tDx[i] - dx[i]) * a;
        const nsx = sx[i] + (tSx[i] - sx[i]) * a;
        const nsy = sy[i] + (tSy[i] - sy[i]) * a;
        vDx[i] = (ndx - dx[i]) / dt;
        vSx[i] = (nsx - sx[i]) / dt;
        vSy[i] = (nsy - sy[i]) / dt;
        dx[i] = ndx;
        sx[i] = nsx;
        sy[i] = nsy;
      }
    };

    // ambient field position: a slow cosine sweep between the line's ends
    const ambientCenter = (elapsedMs: number) => {
      const span = baseC[n - 1] - baseC[0] || 1;
      const t = (elapsedMs % AMBIENT_PERIOD) / AMBIENT_PERIOD;
      const f = (1 - Math.cos(t * Math.PI * 2)) / 2; // 0 -> 1 -> 0
      return baseC[0] + span * f;
    };

    const loop = (now: number) => {
      const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);
      last = now;
      let fieldPeak = 0;

      if (hovered) {
        fieldPeak = computeTargets(cursorX, swell, pull);
        approach(dt, 0.85); // lerp ~0.15/frame — responsive to the real cursor
      } else if (releasing) {
        // release: every span snaps back on an underdamped spring — the
        // ligatures pinch apart with surface-tension overshoot
        let settled = true;
        let maxDeviation = 0;
        for (let i = 0; i < n; i++) {
          let v = vDx[i];
          let p = dx[i];
          v += (-K * p - C * v) * dt;
          p += v * dt;
          vDx[i] = v;
          dx[i] = p;

          v = vSx[i];
          let s = sx[i];
          v += (-K * (s - 1) - C * v) * dt;
          s += v * dt;
          vSx[i] = v;
          sx[i] = s;

          v = vSy[i];
          s = sy[i];
          v += (-K * (s - 1) - C * v) * dt;
          s += v * dt;
          vSy[i] = v;
          sy[i] = s;

          maxDeviation = Math.max(
            maxDeviation,
            Math.abs(dx[i]) / Math.max(1, pull),
            Math.abs(sy[i] - 1) / Math.max(1e-3, swell)
          );
          if (
            Math.abs(dx[i]) > 0.02 ||
            Math.abs(vDx[i]) > 0.5 ||
            Math.abs(sx[i] - 1) > 0.002 ||
            Math.abs(vSx[i]) > 0.05 ||
            Math.abs(sy[i] - 1) > 0.002 ||
            Math.abs(vSy[i]) > 0.05
          ) {
            settled = false;
          }
        }
        fieldPeak = Math.min(1, maxDeviation);
        if (settled) {
          for (let i = 0; i < n; i++) {
            dx[i] = 0;
            sx[i] = 1;
            sy[i] = 1;
            vDx[i] = 0;
            vSx[i] = 0;
            vSy[i] = 0;
          }
          releasing = false;
          ambientElapsed = 0;
        }
      } else {
        // ambient: no pointer involved — a faint field drifts on its own so
        // the melt is the resting look, not a hover-only trick
        ambientElapsed += dt * 1000;
        const ax = ambientCenter(ambientElapsed);
        fieldPeak = computeTargets(ax, AMBIENT_SWELL, AMBIENT_PULL) * AMBIENT_BLUR_SCALE;
        approach(dt, 0.965); // much slower lerp — a gentle breathing motion
      }

      apply();

      // goo blur ramps off the field itself — near-zero away from any
      // activity, up to `blur` at the field's peak — instead of sitting at a
      // constant blobby value the whole time. Ambient's peak is scaled down
      // (see AMBIENT_BLUR_SCALE) since the sweep sits near *some* glyph
      // almost continuously — full-strength blur there would just recreate
      // the old always-blobby look; only a real hover earns full goo.
      const targetBlur = idleBlur + (blur - idleBlur) * fieldPeak;
      blurCur += (targetBlur - blurCur) * Math.min(1, dt * 6);
      blurRef.current?.setAttribute("stdDeviation", blurCur.toFixed(3));

      raf = visible ? requestAnimationFrame(loop) : 0;
    };

    const wake = () => {
      if (!raf && visible) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };
    const onMove = (e: PointerEvent) => {
      cursorX = e.clientX - root.getBoundingClientRect().left;
      hovered = true;
      releasing = false;
      wake();
    };
    const onLeave = () => {
      if (!hovered) return;
      hovered = false;
      releasing = true;
      wake();
    };
    // keyboard/focus parity: a tab-focused line melts centered on itself,
    // same as a cursor parked mid-line — blur triggers the same release spring
    const onFocus = () => {
      cursorX = (baseC[0] + baseC[n - 1]) / 2;
      hovered = true;
      releasing = false;
      wake();
    };

    root.addEventListener("pointermove", onMove);
    root.addEventListener("pointerdown", onMove);
    root.addEventListener("pointerleave", onLeave);
    root.addEventListener("pointerup", onLeave);
    root.addEventListener("pointercancel", onLeave);
    root.addEventListener("focus", onFocus);
    root.addEventListener("blur", onLeave);
    const ro = new ResizeObserver(measure);
    ro.observe(line);
    // ambient motion never "settles", so sleeping means pausing offscreen
    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible) wake();
      else if (raf) {
        cancelAnimationFrame(raf);
        raf = 0;
        last = 0;
      }
    });
    io.observe(root);

    wake(); // ambient drift is the default look — start immediately

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      root.removeEventListener("pointermove", onMove);
      root.removeEventListener("pointerdown", onMove);
      root.removeEventListener("pointerleave", onLeave);
      root.removeEventListener("pointerup", onLeave);
      root.removeEventListener("pointercancel", onLeave);
      root.removeEventListener("focus", onFocus);
      root.removeEventListener("blur", onLeave);
      for (const el of spans) el.style.transform = "";
    };
  }, [text, sigma, swell, pull, blur, idleBlur]);

  return (
    <span
      ref={rootRef}
      role="button"
      tabIndex={0}
      className={`relative inline-block rounded-sm outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent ${className}`}
    >
      {/* blur + alpha threshold: overlapping glyph edges fuse into necks */}
      <svg aria-hidden focusable="false" className="absolute h-0 w-0">
        <defs>
          <filter
            id={filterId}
            x="-30%"
            y="-60%"
            width="160%"
            height="220%"
            colorInterpolationFilters="sRGB"
          >
            <feGaussianBlur
              ref={blurRef}
              in="SourceGraphic"
              stdDeviation={idleBlur}
              result="b"
            />
            <feColorMatrix
              in="b"
              type="matrix"
              values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 19 -9"
            />
          </filter>
        </defs>
      </svg>
      <span
        ref={lineRef}
        role="text"
        aria-label={text}
        className="inline-block whitespace-pre"
        style={{ filter: `url(#${filterId})` }}
      >
        {glyphs.map((c, i) => (
          <span key={i} aria-hidden className="inline-block will-change-transform">
            {c}
          </span>
        ))}
      </span>
    </span>
  );
}
Use when

glyphs liquefy and fuse into gooey ligatures near the cursor via an SVG filter, drifting even at rest and springing apart on leave; use for a squishy, organic headline, not a legibility-first reveal.

Build spec

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.

Tags
textcursorsvg-filtergoomicro-interaction