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>
);
}