Original by Plinth · MIT
Text Effects
Text Morph
One phrase dissolves into the next letter by letter, the box widening to meet the new words.
#text#morph#blur#transition#animation
Install
npx shadcn@latest add https://plinthui.com/r/text-morph.json'use client';
import { useEffect, useRef, useState } from 'react';
// Per-character blur transitions and an animating track width are outside what
// Tailwind can express, so the material lives in the style element below.
const css = `
.morph {
position: relative;
margin: 0;
font: 600 clamp(20px, 5vw, 34px)/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif;
letter-spacing: -0.02em;
color: #f2efe8;
}
/* inline-flex so the box follows the letters instead of snapping to the new
word's width. */
.morph__track {
display: inline-flex;
align-items: baseline;
transition: width 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
.morph__char {
display: inline-block;
white-space: pre;
transform-origin: 50% 60%;
/* Blur is what sells it: letters dissolve rather than cut, so the eye reads
one word becoming another instead of two words swapping. */
transition:
opacity 320ms ease,
filter 320ms ease,
transform 380ms cubic-bezier(0.22, 1, 0.36, 1);
}
.morph__char--in {
opacity: 0;
filter: blur(6px);
transform: translateY(0.32em) scale(0.9);
}
.morph__char--out {
opacity: 0;
filter: blur(6px);
transform: translateY(-0.3em) scale(0.94);
position: absolute;
pointer-events: none;
}
.morph__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.morph__track, .morph__char { transition: none; }
.morph__char--in, .morph__char--out { opacity: 1; filter: none; transform: none; }
.morph__char--out { display: none; }
}
`;
const HOLD_MS = 2600;
export function TextMorph({
phrases = ['Running late', 'On my way', 'Two minutes out', 'Just arrived'],
}: {
phrases?: string[];
}) {
const [index, setIndex] = useState(0);
const trackRef = useRef<HTMLSpanElement>(null);
const previous = useRef<string | null>(null);
const text = phrases[index % phrases.length] ?? '';
// The morph is DOM choreography — old letters have to keep their place while
// they blur out, which React's reconciler would rather not do. So the track
// is written imperatively and React only owns which phrase is current.
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const animate = previous.current !== null;
previous.current = text;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const timers: ReturnType<typeof setTimeout>[] = [];
// Retire old letters in place: absolutely positioned so they stop taking
// space, letting the width settle to the new word while they fade.
for (const old of Array.from(track.children)) {
if (!animate || reduce) {
old.remove();
continue;
}
old.classList.add('morph__char--out');
timers.push(setTimeout(() => old.remove(), 380));
}
const made: HTMLSpanElement[] = [];
text.split('').forEach((ch, i) => {
const span = document.createElement('span');
span.className = `morph__char${animate && !reduce ? ' morph__char--in' : ''}`;
span.textContent = ch;
// Stagger left to right, so the word arrives like it is being written.
if (animate && !reduce) span.style.transitionDelay = `${i * 22}ms`;
track.appendChild(span);
made.push(span);
});
// Release the pinned width before measuring. scrollWidth on an element with
// an explicit width reports that width, not the content's — measuring while
// still pinned makes the track measure itself and the box never resizes.
// Setting auto and reading in the same task keeps the transition running
// from the last committed width.
track.style.width = 'auto';
const natural = track.scrollWidth;
track.style.width = `${natural}px`;
if (!animate || reduce) return;
const raf = requestAnimationFrame(() =>
requestAnimationFrame(() => {
for (const s of made) s.classList.remove('morph__char--in');
}),
);
return () => {
cancelAnimationFrame(raf);
for (const t of timers) clearTimeout(t);
};
}, [text]);
useEffect(() => {
const id = setInterval(() => setIndex(i => i + 1), HOLD_MS);
return () => clearInterval(id);
}, []);
return (
<>
<style>{css}</style>
<p className="morph">
<span className="morph__track" ref={trackRef} aria-hidden="true" />
{/* Announced as plain text; a per-character rebuild is noise to a
screen reader, so the animated track is hidden from it. */}
<span className="morph__sr" role="status" aria-live="polite">
{text}
</span>
</p>
</>
);
}