Morphing Text

Words liquid-morph into each other — letter shapes genuinely melt and reform, not a crossfade

textmorphgooeyliquidanimation
Preview

Source Code

"use client";

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

interface MorphingTextProps {
  words?: string[];
  morphTime?: number;
  cooldownTime?: number;
  className?: string;
}

export default function MorphingText({
  words = ["Design", "Develop", "Deploy"],
  morphTime = 1,
  cooldownTime = 1.5,
  className = "",
}: MorphingTextProps) {
  const rawId = useId();
  const filterId = `mt-${rawId.replace(/[^a-zA-Z0-9]/g, "")}`;
  const containerRef = useRef<HTMLSpanElement>(null);
  const text1Ref = useRef<HTMLSpanElement>(null);
  const text2Ref = useRef<HTMLSpanElement>(null);
  const wordsKey = JSON.stringify(words);

  useEffect(() => {
    const wordList: string[] = JSON.parse(wordsKey);
    const text1 = text1Ref.current;
    const text2 = text2Ref.current;
    const container = containerRef.current;
    if (!text1 || !text2 || !container) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    if (reduced) {
      container.style.filter = "none";
      text2.style.opacity = "0";
      let index = 0;
      text1.textContent = wordList[0];
      const interval = setInterval(() => {
        index = (index + 1) % wordList.length;
        text1.textContent = wordList[index];
      }, (morphTime + cooldownTime) * 1000);
      return () => clearInterval(interval);
    }

    let textIndex = 0;
    let morph = 0;
    let cooldown = cooldownTime;
    let time = performance.now();
    let raf = 0;

    const setMorph = (fraction: number) => {
      text2.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
      text2.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`;
      const inverse = 1 - fraction;
      text1.style.filter = `blur(${Math.min(8 / inverse - 8, 100)}px)`;
      text1.style.opacity = `${Math.pow(inverse, 0.4) * 100}%`;
    };

    const frame = (now: number) => {
      raf = requestAnimationFrame(frame);
      const dt = (now - time) / 1000;
      time = now;
      const wasCoolingDown = cooldown > 0;
      cooldown -= dt;

      if (cooldown <= 0) {
        if (wasCoolingDown) {
          textIndex = (textIndex + 1) % wordList.length;
          text1.textContent = wordList[textIndex];
          text2.textContent =
            wordList[(textIndex + 1) % wordList.length];
        }
        morph -= cooldown;
        cooldown = 0;
        let fraction = morph / morphTime;
        if (fraction > 1) {
          cooldown = cooldownTime;
          fraction = 1;
        }
        setMorph(fraction);
      } else {
        morph = 0;
        text1.style.filter = "";
        text1.style.opacity = "100%";
        text2.style.filter = "";
        text2.style.opacity = "0%";
      }
    };

    text1.textContent = wordList[0];
    text2.textContent = wordList[1 % wordList.length];
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [wordsKey, morphTime, cooldownTime]);

  return (
    <span
      ref={containerRef}
      className={`relative inline-grid ${className}`}
      style={{ filter: `url(#${filterId})` }}
      aria-label={words.join(", ")}
    >
      <svg className="absolute h-0 w-0" aria-hidden="true">
        <defs>
          <filter id={filterId}>
            <feColorMatrix
              in="SourceGraphic"
              type="matrix"
              values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 255 -140"
            />
          </filter>
        </defs>
      </svg>
      <span
        ref={text1Ref}
        aria-hidden="true"
        className="select-none text-center [grid-area:1/1]"
      />
      <span
        ref={text2Ref}
        aria-hidden="true"
        className="select-none text-center [grid-area:1/1]"
      />
    </span>
  );
}