Skip to content
Plinth

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
Open in v0
'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>
    </>
  );
}

More in Text Effects

Similar tags