A dark agency hero whose main word keeps changing. Instead of sliding or fading, each word blurs, fuses with the next one into a single blob of ink and then sharpens into the new word. A short label sits above it, with a paragraph, two buttons and a line of client names below. The word scales with the screen width on phones, so the longest entry still fits on one line.
// Adapted from Magic UI (https://magicui.design/docs/components/morphing-text)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
/** The words the headline melts between, in order. */
const words = ["Identities", "Websites", "Packaging", "Campaigns", "Signage"];
/** Seconds one melt takes, and seconds each word then rests. */
const MORPH_TIME = 1.4;
const HOLD_TIME = 1.1;
const clients = ["Marrow & Rye", "Tidewater Co-op", "Lumen Cycles", "Hask Hotels"];
function MorphingText({ texts }: { texts: string[] }) {
const box = useRef<HTMLDivElement>(null);
const first = useRef<HTMLSpanElement>(null);
const second = useRef<HTMLSpanElement>(null);
useEffect(() => {
const a = first.current;
const b = second.current;
const el = box.current;
if (!a || !b || !el) return;
const settle = (i: number) => {
a.textContent = texts[i % texts.length];
b.textContent = texts[(i + 1) % texts.length];
a.style.filter = b.style.filter = "none";
a.style.opacity = "1";
b.style.opacity = "0";
};
// Reduced motion: plain swap, no melting.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
let i = 0;
settle(0);
const id = window.setInterval(() => settle(++i), 3000);
return () => window.clearInterval(id);
}
let index = 0;
let morph = 0;
let hold = HOLD_TIME;
let last = performance.now();
let frame = 0;
let visible = true;
const paint = (fraction: number) => {
const f = Math.max(fraction, 0.0001);
const inv = Math.max(1 - fraction, 0.0001);
b.style.filter = `blur(${Math.min(8 / f - 8, 100)}px)`;
b.style.opacity = `${Math.pow(f, 0.4)}`;
a.style.filter = `blur(${Math.min(8 / inv - 8, 100)}px)`;
a.style.opacity = `${Math.pow(inv, 0.4)}`;
};
const tick = (now: number) => {
frame = requestAnimationFrame(tick);
const dt = Math.min((now - last) / 1000, 0.1);
last = now;
if (hold > 0) {
hold -= dt;
return;
}
morph += dt;
const fraction = Math.min(morph / MORPH_TIME, 1);
paint(fraction);
if (fraction === 1) {
index++;
morph = 0;
hold = HOLD_TIME;
settle(index);
}
};
const start = () => {
if (frame || !visible || document.hidden) return;
last = performance.now();
frame = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(frame);
frame = 0;
};
settle(0);
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) start();
else stop();
});
io.observe(el);
const onVisibility = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVisibility);
start();
return () => {
stop();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [texts]);
const layer = "absolute inset-x-0 top-0 block w-full whitespace-nowrap text-center";
return (
<div
ref={box}
aria-hidden="true"
className="relative mx-auto h-[1.15em] w-full text-[15vw] font-bold leading-none tracking-tight sm:text-8xl lg:text-9xl"
style={{ filter: "url(#morphing-text-headline-with-liquid-blur-threshold) blur(0.6px)" }}
>
<span ref={first} className={layer}>{texts[0]}</span>
<span ref={second} className={layer} style={{ opacity: 0 }}>{texts[1]}</span>
{/* Turns soft blurred edges back into hard ones, which is what makes two words fuse like liquid. */}
<svg className="absolute h-0 w-0" aria-hidden="true">
<defs>
<filter id="morphing-text-headline-with-liquid-blur-threshold">
<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>
</div>
);
}
export default function MorphingTextHeadlineWithLiquidBlur() {
return (
<section className="relative overflow-hidden bg-[#10131a] px-4 py-24 text-white sm:px-6 sm:py-32">
<div aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2 h-[520px] w-[820px] max-w-none -translate-x-1/2 -translate-y-1/2 rounded-full bg-rose-500/20 blur-3xl" />
<div aria-hidden="true" className="pointer-events-none absolute inset-0 opacity-[0.07]" style={{ backgroundImage: "radial-gradient(#fff 1px, transparent 1px)", backgroundSize: "22px 22px" }} />
<div className="relative mx-auto max-w-5xl text-center">
<p className="font-mono text-sm uppercase tracking-[0.3em] text-rose-300">Fernwood Studio makes</p>
<h1 className="mt-6 text-rose-50">
<span className="sr-only">{words.join(", ")}</span>
<MorphingText texts={words} />
</h1>
<p className="mx-auto mt-8 max-w-xl text-lg leading-relaxed text-slate-300">
One small team for the whole brand, from the first sketch of a logo to the sign above the door. Fixed quotes, weekly reviews, no account managers.
</p>
<div className="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row">
<a href="#" className="inline-flex h-12 items-center rounded-xl bg-rose-500 px-7 text-sm font-semibold text-white transition-colors hover:bg-rose-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950">
Start a project
</a>
<a href="#" className="inline-flex h-12 items-center rounded-xl border border-white/20 px-7 text-sm font-semibold text-white transition-colors hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-300">
See recent work
</a>
</div>
<ul className="mt-16 flex flex-wrap items-center justify-center gap-x-10 gap-y-3 border-t border-white/10 pt-8 text-sm font-medium text-slate-400">
<li className="text-slate-300">Recently for</li>
{clients.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/morphing-text-headline-with-liquid-blur.json
Two copies of the text sit on top of each other. One is blurred out while the other is blurred in. On its own that is a soft cross-fade, but the wrapper has an SVG filter that pushes every half-transparent pixel to either fully solid or fully clear. Blurred shapes that overlap therefore join into one hard-edged blob, which is what reads as liquid.
It only defines the threshold filter. It has no size and draws nothing. The text wrapper points at it through `filter: url(#...)`. If you use the component twice on a page the shared filter id is fine, as both copies are identical.
The threshold filter works on the alpha channel and keeps the colour, so a single solid colour is the safe choice. Gradients applied with background clipping tend to break under the filter.
It animates a blur on two text layers once per frame, which is light for one headline. Avoid running many of them at once, and keep the observer that stops it off screen.
The two text layers are absolutely positioned so they can overlap, which means they do not give the box any height. The `h-[1.15em]` class reserves one line so the content below does not jump.
Adapted from Morphing Text in Magic UI, used under its open-source licence. Licences and credits
Related: Rotating Word Headline with Spring Flip, Text Scramble Decode Headline, Typewriter Headline with Cycling Phrases.
More text effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.