~/sohan/ghostty
[SYS]sohanscript v2026.1 initialized
[INFO]stack: ts · react · next · trpc
[INFO]location: dhaka, bangladesh
[STAT]STATUS: OPEN FOR WORK
Indexing assets0%
○ waitingmeasuring...
~
Connection
Latency: <1ms
Kernel
Aura Engine v4.2
Skip to main content
cf576cb457eab3eb658a
·4 min read

React Text Morphing with SVG Blur Gate

Build a smooth text crossfade in React using stacked elements, requestAnimationFrame, and an SVG feColorMatrix blur gate. No animation libraries required.

react
animation
svg
css
typescript
crossfade
frontend
3ef4f885272bc6323209

Sohan R. Emon

Developer, Learner, Tech Enthusiast

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:

~/crossfade-text
"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:

txt
blurIn  = 100 × (1 − p²)          // incoming: sharp → blurry → sharp
blurOut = 100 × (1 − (1−p)²)      // outgoing: sharp → blurry

Opacity uses ease-in-out quadratic (S-curve):

txt
fadeIn  = p < 0.5 ? 2p² : 1 − (−2p + 2)² / 2
fadeOut = 1 − fadeIn

The 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 - elapsed accumulates dt, p goes 0 to 1. Blur and opacity animate both spans.
  • Hold phase - hold counts down. Tail stays visible, head stays hidden.
  • Rollover - hold hits zero, elapsed resets, next crossfade starts.

No doMorph/doCooldown split. No extra animate function. The rAF loop is two lines:

tsx
const loop = (t: number) => {
  id = requestAnimationFrame(loop);
  tick(t);
};

SVG Filter

xml
<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

tsx
<WordCrossfade
  words={["creative", "pragmatic", "curious", "building"]}
  className="text-4xl font-bold"
/>

Found this useful? Share!