Skip to main content

ns-ui

Text Ligature Melt

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.

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; pick text-stitch-unpick instead when the cursor should unravel letters rather than fuse them, text-prism-split when the distortion should be chromatic and glitchy rather than gooey, or text-variable-weight when the response should be pure weight with no filter at all.

Install

npx shadcn add https://design.helpmarq.com/r/text-ligature-melt.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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;
  /** extra classes merged onto the rendered root element */
  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 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-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>
  );
}
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.

Props

PropTypeDefaultDescription
text?string"SURFACE TENSION"headline text — rendered as real, selectable DOM glyphs
sigma?number70gaussian falloff radius of the melt field in px
swell?number0.35max extra scale at zero distance (0.35 → 1.35x)
pull?number6max translation toward the cursor in px — creates the overlap that goos
blur?number6feGaussianBlur stdDeviation at full field strength — ramps down to near-zero at rest
className?stringextra classes merged onto the rendered root element