← All moods

Liquid Morph

The word renders as one gooey outline, with neighboring letters fused by soft liquid joints. Every letter can be dragged.

React · Motion

Outline
Fill
Background

LiquidMorph.tsx

"use client";

import {
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type RefObject,
} from "react";
import { motion, motionValue, useAnimationFrame } from "motion/react";

export interface LiquidMorphProps {
  text: string;
  /** Outline color. Defaults to the text color. */
  color?: string;
  /** Fill inside the outline. "none" = outline only. */
  fill?: string;
  /** Outline thickness, em. 0 with a fill = solid blobs. */
  strokeWidth?: number;
  /** Goo softness, em. Bigger = rounder letters and wider joints. Too big and
   *  thin strokes melt away. */
  goo?: number;
  /** Space between letters, em. Negative makes neighbors start out joined. */
  tracking?: number;
  /** Thickness of the liquid neck between joined letters, em. */
  neck?: number;
  /** How far (em) joined letters can be pulled apart before the neck snaps. */
  maxStretch?: number;
  /** Let the viewer drag letters around. */
  draggable?: boolean;
  /** Keep dragged letters inside this element. */
  dragBounds?: RefObject<Element | null>;
  /** Double-click a letter to send every letter home. */
  resetOnDoubleClick?: boolean;
  className?: string;
  style?: CSSProperties;
}

interface Home {
  cx: number;
  cy: number;
  w: number;
  h: number;
}

export function LiquidMorph({
  text,
  color = "currentColor",
  fill = "none",
  strokeWidth = 0.01,
  goo = 0.06,
  tracking = -0.04,
  neck = 0.32,
  maxStretch = 0.5,
  draggable = true,
  dragBounds,
  resetOnDoubleClick = true,
  className,
  style,
}: LiquidMorphProps) {
  const filterId = `liquid-${useId().replace(/:/g, "")}`;
  const chars = useMemo(() => Array.from(text), [text]);
  // Changing the key remounts the letters, which puts them back home.
  const [resetKey, setResetKey] = useState(0);

  // Each letter's drag offset. Recreated on reset so everything starts at 0.
  const offsets = useMemo(
    () => chars.map(() => ({ x: motionValue(0), y: motionValue(0) })),
    [chars, resetKey],
  );

  // Every pair of (non-space) letters could be joined by a neck.
  const pairs = useMemo(() => {
    const list: [number, number][] = [];
    chars.forEach((a, i) =>
      chars.forEach((b, j) => {
        if (j > i && a.trim() && b.trim()) list.push([i, j]);
      }),
    );
    return list;
  }, [chars]);

  // Neighbours start out joined (a space between them breaks the join).
  const bonds = useRef(new Set<string>());
  useLayoutEffect(() => {
    bonds.current = new Set(
      chars.flatMap((c, i) => (c.trim() && chars[i + 1]?.trim() ? [`${i}-${i + 1}`] : [])),
    );
  }, [chars, resetKey]);

  // The filter works in pixels, so it needs the font size. Letter home
  // centers are cached and re-read when anything resizes.
  const rootRef = useRef<HTMLSpanElement>(null);
  const letterRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const neckRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const homes = useRef<Home[]>([]);
  const [fontSize, setFontSize] = useState(0);
  useLayoutEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const read = () => {
      setFontSize(parseFloat(getComputedStyle(el).fontSize) || 16);
      homes.current = letterRefs.current.map((l) =>
        l
          ? {
              cx: l.offsetLeft + l.offsetWidth / 2,
              cy: l.offsetTop + l.offsetHeight / 2,
              w: l.offsetWidth,
              h: l.offsetHeight,
            }
          : { cx: 0, cy: 0, w: 0, h: 0 },
      );
    };
    read();
    const observer = new ResizeObserver(read);
    observer.observe(el);
    document.fonts?.addEventListener("loadingdone", read);
    return () => {
      observer.disconnect();
      document.fonts?.removeEventListener("loadingdone", read);
    };
  }, [chars, resetKey]);

  // Necks: joined letters are linked by a bar that thins as they're pulled
  // apart and snaps past `maxStretch`. Letters that touch join up. The goo
  // filter turns the bar and the letters into one liquid shape.
  useAnimationFrame(() => {
    const fs = fontSize;
    if (!fs) return;
    pairs.forEach(([a, b], k) => {
      const el = neckRefs.current[k];
      const ha = homes.current[a];
      const hb = homes.current[b];
      if (!el || !ha || !hb) return;
      const ax = ha.cx + offsets[a].x.get();
      const ay = ha.cy + offsets[a].y.get();
      const bx = hb.cx + offsets[b].x.get();
      const by = hb.cy + offsets[b].y.get();
      const dist = Math.hypot(bx - ax, by - ay);
      // How far past "just touching" the two letters are.
      const stretch = dist - ((ha.w + hb.w) / 2 + tracking * fs);
      const key = `${a}-${b}`;
      if (bonds.current.has(key)) {
        if (stretch > maxStretch * fs) bonds.current.delete(key);
      } else if (stretch < 0) {
        bonds.current.add(key);
      }
      // Touching letters already merge through the goo; a neck is only
      // needed once they're pulled apart.
      if (!bonds.current.has(key) || stretch <= 0) {
        el.style.display = "none";
        return;
      }
      // The neck spans just the gap: from a bit inside one letter's edge to a
      // bit inside the other's (edges approximated by an ellipse per letter),
      // so it never fills the letters themselves.
      const angle = Math.atan2(by - ay, bx - ax);
      const edge = (h: Home) =>
        0.75 / Math.hypot(Math.cos(angle) / (h.w / 2 || 1), Math.sin(angle) / (h.h * 0.35 || 1));
      const from = edge(ha);
      const length = Math.max(0, dist - from - edge(hb));
      const thin = Math.min(1, stretch / (maxStretch * fs));
      const th = neck * fs * (1 - thin) ** 1.2;
      el.style.display = "block";
      el.style.width = `${length}px`;
      el.style.height = `${th}px`;
      el.style.borderRadius = `${th / 2}px`;
      el.style.transform =
        `translate(${ax + Math.cos(angle) * from}px, ${ay + Math.sin(angle) * from - th / 2}px) rotate(${angle}rad)`;
    });
  });

  const blur = goo * fontSize;
  const stroke = strokeWidth * fontSize;
  const hasFill = fill !== "none";

  return (
    <span
      ref={rootRef}
      role="img"
      aria-label={text}
      className={className}
      style={{ position: "relative", display: "inline-block", padding: `${goo * 2}em`, lineHeight: 1, ...style }}
    >
      <svg aria-hidden width="0" height="0" style={{ position: "absolute" }}>
        <defs>
          {/* Huge user-space region so dragged letters are never clipped. */}
          <filter
            id={filterId}
            filterUnits="userSpaceOnUse"
            x="-4000"
            y="-4000"
            width="8000"
            height="8000"
            colorInterpolationFilters="sRGB"
          >
            {/* 1. Blur the shapes so nearby ones bleed into each other. */}
            <feGaussianBlur in="SourceAlpha" stdDeviation={blur} result="blur" />
            {/* 2. Threshold the blur back to a hard edge: the gooey shape.
                   Where two blurs overlap they add up past the threshold and
                   a smooth liquid joint fills the gap. */}
            <feColorMatrix
              in="blur"
              mode="matrix"
              values="0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 24 -8"
              result="goo"
            />
            {/* 3. Outline = goo minus a slightly shrunk copy of itself. */}
            <feMorphology in="goo" operator="erode" radius={stroke} result="inner" />
            <feComposite in="goo" in2="inner" operator="out" result="ring" />
            <feFlood floodColor={color} result="lineColor" />
            <feComposite in="lineColor" in2={stroke > 0 ? "ring" : "goo"} operator="in" result="line" />
            {hasFill && (
              <>
                <feFlood floodColor={fill} result="fillColor" />
                <feComposite in="fillColor" in2="goo" operator="in" result="body" />
              </>
            )}
            <feMerge>
              {hasFill && <feMergeNode in="body" />}
              {(stroke > 0 || !hasFill) && <feMergeNode in="line" />}
            </feMerge>
          </filter>
        </defs>
      </svg>

      <span
        key={resetKey}
        aria-hidden
        style={{
          position: "relative",
          display: "inline-flex",
          gap: `${tracking}em`,
          filter: fontSize ? `url(#${filterId})` : undefined,
          visibility: fontSize ? "visible" : "hidden",
          whiteSpace: "pre",
        }}
      >
        {pairs.map((_, k) => (
          <span
            key={`neck-${k}`}
            ref={(el) => {
              neckRefs.current[k] = el;
            }}
            style={{
              position: "absolute",
              left: 0,
              top: 0,
              display: "none",
              background: "#000",
              transformOrigin: "0 50%",
              pointerEvents: "none",
            }}
          />
        ))}
        {chars.map((char, i) => (
          <motion.span
            key={i}
            ref={(el: HTMLSpanElement | null) => {
              letterRefs.current[i] = el;
            }}
            drag={draggable && char.trim() !== ""}
            dragMomentum={false}
            dragConstraints={dragBounds}
            dragElastic={0.15}
            onDoubleClick={resetOnDoubleClick ? () => setResetKey((k) => k + 1) : undefined}
            whileDrag={{ scale: 1.06, cursor: "grabbing" }}
            style={{
              x: offsets[i].x,
              y: offsets[i].y,
              display: "inline-block",
              cursor: draggable ? "grab" : undefined,
              touchAction: draggable ? "none" : undefined,
              userSelect: "none",
            }}
          >
            {char}
          </motion.span>
        ))}
      </span>
    </span>
  );
}