The Core Idea
Stack two text elements and crossfade them through a blur gate. An SVG feColorMatrix filter sharpens the overlap into readable text. No canvas, no animation library. Just requestAnimationFrame and a handful of CSS properties. You can see it on the homepage, that rotating tagline thing.
TLDR
Full component, one file:
"use client";
import { cn } from "@sohanemon/utils";
import { useCallback, useEffect, useRef } from "react";
const CROSSFADE_MS = 1800;
const STILL_MS = 600;
interface WordCrossfadeProps {
words: string[];
className?: string;
}
export function WordCrossfade({ words, className }: WordCrossfadeProps) {
const head = useRef<HTMLSpanElement>(null);
const tail = useRef<HTMLSpanElement>(null);
const index = useRef(0);
const elapsed = useRef(0);
const hold = useRef(0);
const prev = useRef(performance.now());
const tick = useCallback(
(t: number) => {
const dt = t - prev.current;
prev.current = t;
if (hold.current > 0) {
hold.current -= dt;
if (hold.current > 0) {
if (head.current && tail.current) {
tail.current.style.filter = "none";
tail.current.style.opacity = "1";
head.current.style.filter = "none";
head.current.style.opacity = "0";
}
return;
}
elapsed.current = 0;
hold.current = 0;
}
elapsed.current += dt;
const p = Math.min(elapsed.current / CROSSFADE_MS, 1);
const blurIn = Math.round(100 * (1 - p * p));
const blurOut = Math.round(100 * (1 - (1 - p) * (1 - p)));
const fadeIn = p < 0.5 ? 2 * p * p : 1 - (-2 * p + 2) ** 2 / 2;
const fadeOut = 1 - fadeIn;
if (head.current && tail.current) {
const a = words[index.current % words.length];
const b = words[(index.current + 1) % words.length];
head.current.textContent = a;
tail.current.textContent = b;
tail.current.style.filter = `blur(${blurIn}px)`;
tail.current.style.opacity = `${fadeIn}`;
head.current.style.filter = `blur(${blurOut}px)`;
head.current.style.opacity = `${fadeOut}`;
}
if (p >= 1) {
index.current = (index.current + 1) % words.length;
hold.current = STILL_MS;
}
},
[words],
);
useEffect(() => {
let id: number;
const loop = (t: number) => {
id = requestAnimationFrame(loop);
tick(t);
};
id = requestAnimationFrame(loop);
return () => cancelAnimationFrame(id);
}, [tick]);
return (
<span
className={cn(
"relative w-full shrink-0",
className ?? "filter-[url(#crossfade)_blur(.6px)]",
)}
>
<span className="absolute" ref={head}>
{words[0]}
</span>
<span ref={tail}>{words[1 % words.length]}</span>
<svg className="hidden" aria-hidden>
<defs>
<filter id="crossfade">
<feColorMatrix
in="SourceGraphic"
type="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 220 -80"
/>
</filter>
</defs>
</svg>
<span className="invisible" aria-hidden>.</span>
</span>
);
}How It Works
Two span refs. head is the outgoing word, absolutely positioned. tail is the incoming word, in normal flow. One tick function runs everything. No extra hooks, no separate SVG component. That's the whole thing.
The Crossfade Math
Every frame tick computes a progress p from 0→1. Blur uses quadratic falloff:
blurIn = 100 × (1 − p²) // incoming: sharp → blurry → sharp
blurOut = 100 × (1 − (1−p)²) // outgoing: sharp → blurryOpacity uses ease-in-out quadratic (S-curve):
fadeIn = p < 0.5 ? 2p² : 1 − (−2p + 2)² / 2
fadeOut = 1 − fadeInThe quadratic blur gives a tighter overlap window than linear, so the threshold filter catches a cleaner cross-section.
Everything in one tick
tick gets the rAF timestamp directly. No Date.now(), no manual delta. Two branches in one function:
- Crossfade phase -
elapsedaccumulatesdt,pgoes 0 to 1. Blur and opacity animate both spans. - Hold phase -
holdcounts down. Tail stays visible, head stays hidden. - Rollover - hold hits zero,
elapsedresets, next crossfade starts.
No doMorph/doCooldown split. No extra animate function. The rAF loop is two lines:
const loop = (t: number) => {
id = requestAnimationFrame(loop);
tick(t);
};SVG Filter
<filter id="crossfade">
+ <feColorMatrix
+ in="SourceGraphic"
+ type="matrix"
+ values="1 0 0 0 0
+ 0 1 0 0 0
+ 0 0 1 0 0
+ 0 0 0 220 -80"
+ />
</filter>A' = 220A - 80. Pixels below about 36% alpha disappear. Only overlapping blur regions where both words contribute enough density make it through.
Usage
<WordCrossfade
words={["creative", "pragmatic", "curious", "building"]}
className="text-4xl font-bold"
/>