1. Blurring and clumping the edges together
The heart of the gooey effect is actually a smart optical illusion. First, we blur the letters so they bleed into each other like pools of ink. Then, we apply a high-contrast filter that sharpens the soft blur boundaries. Where the blurred letters touch, the filter fuses them together into a single liquid form, making them look like sticky droplets.
<svg className="absolute h-0 w-0" aria-hidden="true" focusable="false">
<defs>
<filter id="threshold">
{/* feColorMatrix maps pixel colors and alphas through a 5x4 transformation matrix. */}
{/* This matrix sets the R, G, B output unchanged, but multiplies the Alpha channel by 255 and subtracts 140. */}
{/* This creates a hard-cutoff threshold: either fully opaque or fully transparent. */}
<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>2. The magic contrast numbers
The grid of numbers in our filter might look intimidating, but they are just doing simple math on transparency. This filter screens every pixel: if a spot is mostly transparent, it deletes it entirely. If a spot is partially opaque (like where our blurs merge), it bumps it up to fully opaque. This removes any blurry shadows and leaves us with crisp, solid-looking fluid shapes.
/* Affine Transformation Matrix Mapping */
[
1, 0, 0, 0, 0, // Red Channel output remains identical
0, 1, 0, 0, 0, // Green Channel output remains identical
0, 0, 1, 0, 0, // Blue Channel output remains identical
0, 0, 0, 255, -140 // Alpha Output = (Alpha * 255) - 140
]3. Blending two words smooth and even
To swap words nicely, we display both words at the exact same location. As one word fades out and blurs, the other fades in and sharpens. By calculating their transparency using a smooth curved formula, we make sure they blend evenly and never look half-visible or awkward.
const setMorph = (fraction: number) => {
if (text1Ref.current && text2Ref.current) {
// Blur and fade-in the entering text
text2Ref.current.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
text2Ref.current.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`;
// Reverse-blur and fade-out the exiting text
fraction = 1 - fraction;
text1Ref.current.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
text1Ref.current.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`;
}
};4. Keeping the animation butter-smooth
Using standard timer functions in React can cause choppy jumps or skips on nice monitors. Instead, we hook into the browser's native animation scheduler (requestAnimationFrame). This runs our calculations in perfect sync with the user's screen refresh rate, making the morph liquid-smooth.
function animate() {
requestAnimationFrame(animate);
const newTime = new Date();
const shouldIncrementIndex = cooldown > 0;
const dt = (newTime.getTime() - time.getTime()) / 1000;
time = newTime;
cooldown -= dt;
if (cooldown <= 0) {
if (shouldIncrementIndex) {
textIndex = (textIndex + 1) % texts.length;
if (text1Ref.current && text2Ref.current) {
text1Ref.current.textContent = texts[textIndex % texts.length];
text2Ref.current.textContent = texts[(textIndex + 1) % texts.length];
}
}
doMorph();
} else {
doCooldown();
}
}5. Putting it all together
Below is the fully functional React code for this component. It is lightweight, fully typed with typescript, runs cleanly on all devices, and manages its own memory so your site stays fast and responsive.
"use client";
import * as React from "react";
interface GooeyTextProps {
texts: string[];
morphTime?: number;
cooldownTime?: number;
className?: string;
textClassName?: string;
}
export function GooeyText({
texts,
morphTime = 1,
cooldownTime = 0.25,
className = "",
textClassName = ""
}: GooeyTextProps) {
const text1Ref = React.useRef<HTMLSpanElement>(null);
const text2Ref = React.useRef<HTMLSpanElement>(null);
React.useEffect(() => {
let textIndex = texts.length - 1;
let time = new Date();
let morph = 0;
let cooldown = cooldownTime;
const setMorph = (fraction: number) => {
if (text1Ref.current && text2Ref.current) {
text2Ref.current.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
text2Ref.current.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`;
fraction = 1 - fraction;
text1Ref.current.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
text1Ref.current.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`;
}
};
const doCooldown = () => {
morph = 0;
if (text1Ref.current && text2Ref.current) {
text2Ref.current.style.filter = "";
text2Ref.current.style.opacity = "100%";
text1Ref.current.style.filter = "";
text1Ref.current.style.opacity = "0%";
}
};
const doMorph = () => {
morph -= cooldown;
cooldown = 0;
let fraction = morph / morphTime;
if (fraction > 1) {
cooldown = cooldownTime;
fraction = 1;
}
setMorph(fraction);
};
let animationId: number;
function animate() {
animationId = requestAnimationFrame(animate);
const newTime = new Date();
const shouldIncrementIndex = cooldown > 0;
const dt = (newTime.getTime() - time.getTime()) / 1000;
time = newTime;
cooldown -= dt;
if (cooldown <= 0) {
if (shouldIncrementIndex) {
textIndex = (textIndex + 1) % texts.length;
if (text1Ref.current && text2Ref.current) {
text1Ref.current.textContent = texts[textIndex % texts.length];
text2Ref.current.textContent = texts[(textIndex + 1) % texts.length];
}
}
doMorph();
} else {
doCooldown();
}
}
animate();
return () => {
cancelAnimationFrame(animationId);
};
}, [texts, morphTime, cooldownTime]);
return (
<div className={`relative ${className}`}>
<svg className="absolute h-0 w-0" aria-hidden="true" focusable="false">
<defs>
<filter id="threshold">
<feColorMatrix
in="SourceGraphic"
type="matrix"
values="1 0 0 0 0\n 0 1 0 0 0\n 0 0 1 0 0\n 0 0 0 255 -140"
/>
</filter>
</defs>
</svg>
<div
className="flex items-center justify-center"
style={{ filter: "url(#threshold)" }}
>
<span
ref={text1Ref}
className={`absolute inline-block select-none text-center text-6xl md:text-[60pt] text-white font-headline font-bold ${textClassName}`}
/>
<span
ref={text2Ref}
className={`absolute inline-block select-none text-center text-6xl md:text-[60pt] text-white font-headline font-bold ${textClassName}`}
/>
</div>
</div>
);
}