Text morph

Text that changes the way it should: shared letters slide over, new ones rise out of a blur, the width eases.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/text-morph.json

Overview

Most interfaces swap text in one frame: "Save" is simply replaced by "Saved". Here the letters both words share stay alive and slide to their new places, new letters rise in out of a soft blur one after another, and the ones that leave lift away where they stood. The box eases to its new width, so the button or pill around it resizes in the same motion. Words with little in common crossfade as a whole instead of flinging single letters across. Use it for any label that changes.

States

shared letters
"Save" → "Saving" keeps "Sav" and glides it; only "ing" is new.
new letters
Rise 0.35em out of a 4px blur, 16ms apart, easing out over 460ms.
leaving letters
Lift the other way and blur out where they stood, in 60% of the time.
width
The box eases from its old width to its new one, carrying its container with it.
unrelated words
With less than half their letters in common, the texts crossfade as a whole.

Usage

import { TextMorph } from "@/components/text-morph";

<button>
  <TextMorph>{saved ? "Saved" : "Save"}</TextMorph>
</button>

Works with

Built on it

In a headline

Morph one word inside a sentence; the sentence reflows around it smoothly.

"use client";
import { useEffect, useState } from "react";
import { TextMorph } from "@/components/text-morph";

const audiences = ["founders", "designers", "agencies"];

export function Headline() {
  const [i, setI] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => setI((n) => (n + 1) % audiences.length), 2200);
    return () => clearInterval(timer);
  }, []);

  return (
    <h1 className="text-5xl font-medium tracking-tight">
      Landing pages for <TextMorph className="text-primary">{audiences[i]}</TextMorph>
    </h1>
  );
}

Props

PropTypeDescription
childrenstringThe text. Change it and it morphs.
durationnumberdefault 460Morph time in ms.
direction"up" | "down"default "up"New letters rise from below ("up") or drop from above ("down").
animateWidthbooleandefault trueEase the width; turn off for text that wraps over several lines.

Accessibility and motion

  • The real text is in the page for screen readers and search; the moving letters are hidden from assistive tech.
  • Words never break mid-word: letters are grouped by word, so lines only wrap at spaces. While the width eases, the words hold to one line, so "Credits left" never wraps halfway into "No credits left".
  • Uses the Web Animations API, with no animation library. Interrupting a morph mid-flight continues smoothly from where the letters are.
  • With reduced motion the text changes in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.

Source

›Show the full source of text-morph.tsx (205 lines)
"use client";

import * as React from "react";

/* ─────────────────────────────────────────────────────────
 * TEXT MORPH: text that changes the way it should
 *
 *   shared   letters both texts share stay alive and slide to
 *            their new place ("Save" → "Saving" keeps "Sav")
 *   new      letters that weren't there rise in out of a blur,
 *            one after another
 *   gone     letters that leave lift away and blur out where
 *            they stood
 *   width    the box eases to its new width, so a button or
 *            pill holding it resizes with it
 *
 * Texts with little in common crossfade as a whole instead of
 * flinging single letters across. The real text is always
 * there for screen readers.
 * ───────────────────────────────────────────────────────── */

export interface TextMorphProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
  children: string;
  /** Morph time in ms. */
  duration?: number;
  /** Where new letters come from: below ("up") or above ("down"). */
  direction?: "up" | "down";
  /** Ease the box's width too; turn off for text that wraps. */
  animateWidth?: boolean;
}

type Glyph = { key: number; char: string };
type Leaving = Glyph & { x: number; y: number };

const EASE = "cubic-bezier(0.16,1,0.3,1)";

const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
  const query = window.matchMedia(reducedQuery);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
  React.useSyncExternalStore(
    subscribeReduced,
    () => window.matchMedia(reducedQuery).matches,
    () => false,
  );

/** Keep the letters two texts share (longest common subsequence); everything else is new. */
function morph(previous: Glyph[], text: string, nextKey: () => number): Glyph[] {
  const a = previous.map((g) => g.char);
  const b = Array.from(text);
  const fresh = () => b.map((char) => ({ key: nextKey(), char }));
  if (!a.length || !b.length) return fresh();

  const dp = Array.from({ length: a.length + 1 }, () => new Uint16Array(b.length + 1));
  for (let i = a.length - 1; i >= 0; i--)
    for (let j = b.length - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);

  // Too little in common reads as two different words: crossfade them instead.
  if (dp[0][0] < Math.min(a.length, b.length) * 0.5) return fresh();

  const out: Glyph[] = [];
  let i = 0;
  let j = 0;
  while (j < b.length) {
    if (i < a.length && a[i] === b[j]) {
      out.push(previous[i++]);
      j++;
    } else if (i < a.length && dp[i + 1][j] >= dp[i][j + 1]) i++;
    else out.push({ key: nextKey(), char: b[j++] });
  }
  return out;
}

function LeavingGlyph({ glyph, lift, duration, onDone }: { glyph: Leaving; lift: number; duration: number; onDone: (key: number) => void }) {
  const ref = React.useRef<HTMLSpanElement>(null);
  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const animation = el.animate(
      [
        { opacity: 1, filter: "blur(0px)", transform: "none" },
        { opacity: 0, filter: "blur(4px)", transform: `translateY(${lift}em)` },
      ],
      { duration: duration * 0.6, easing: EASE, fill: "forwards" },
    );
    animation.onfinish = () => onDone(glyph.key);
    return () => animation.cancel();
  }, [glyph.key, lift, duration, onDone]);
  return (
    <span ref={ref} aria-hidden="true" className="pointer-events-none absolute whitespace-pre" style={{ left: glyph.x, top: glyph.y }}>
      {glyph.char}
    </span>
  );
}

export function TextMorph({ children: text, duration = 460, direction = "up", animateWidth = true, className = "", ...props }: TextMorphProps) {
  const reduced = useReducedMotion();
  const counter = React.useRef(text.length);
  const nextKey = React.useCallback(() => ++counter.current, []);
  const [glyphs, setGlyphs] = React.useState<Glyph[]>(() => Array.from(text).map((char, i) => ({ key: i + 1, char })));
  const [leaving, setLeaving] = React.useState<Leaving[]>([]);
  const rootRef = React.useRef<HTMLSpanElement>(null);
  const els = React.useRef(new Map<number, HTMLSpanElement>());
  const before = React.useRef<{ at: Map<number, { x: number; y: number }>; width: number } | null>(null);
  const rise = direction === "up" ? 0.35 : -0.35;

  // 1. The text changed: note where every letter stands, then work out which letters stay.
  React.useLayoutEffect(() => {
    if (text === glyphs.map((g) => g.char).join("")) return;
    const root = rootRef.current;
    if (!root || reduced) {
      setLeaving([]);
      setGlyphs(Array.from(text).map((char) => ({ key: nextKey(), char })));
      return;
    }
    const box = root.getBoundingClientRect();
    const at = new Map<number, { x: number; y: number }>();
    els.current.forEach((el, key) => {
      const r = el.getBoundingClientRect();
      at.set(key, { x: r.left - box.left, y: r.top - box.top });
    });
    before.current = { at, width: box.width };
    const next = morph(glyphs, text, nextKey);
    const kept = new Set(next.map((g) => g.key));
    setLeaving((l) => [...l, ...glyphs.filter((g) => !kept.has(g.key)).map((g) => ({ ...g, ...(at.get(g.key) ?? { x: 0, y: 0 }) }))]);
    setGlyphs(next);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [text, reduced]);

  // 2. The new letters are in place: glide the ones that stayed, raise the new ones, ease the width.
  React.useLayoutEffect(() => {
    const prev = before.current;
    const root = rootRef.current;
    if (!prev || !root) return;
    before.current = null;
    const box = root.getBoundingClientRect();
    let order = 0;
    for (const g of glyphs) {
      const el = els.current.get(g.key);
      if (!el) continue;
      const from = prev.at.get(g.key);
      if (from) {
        const r = el.getBoundingClientRect();
        const dx = from.x - (r.left - box.left);
        const dy = from.y - (r.top - box.top);
        if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)
          el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }], { duration, easing: EASE });
      } else {
        el.animate(
          [
            { opacity: 0, filter: "blur(4px)", transform: `translateY(${rise}em)` },
            { opacity: 1, filter: "blur(0px)", transform: "none" },
          ],
          { duration, easing: EASE, delay: Math.min(order++ * 16, 160), fill: "backwards" },
        );
      }
    }
    if (animateWidth && Math.abs(prev.width - box.width) > 0.5) {
      // Hold the words on one line while the box is narrower than them, or "No credits left" would wrap mid-morph.
      root.style.whiteSpace = "nowrap";
      const resize = root.animate([{ width: `${prev.width}px` }, { width: `${box.width}px` }], { duration, easing: EASE });
      resize.onfinish = resize.oncancel = () => {
        root.style.whiteSpace = "";
      };
    }
  }, [glyphs, duration, rise, animateWidth]);

  const done = React.useCallback((key: number) => setLeaving((l) => l.filter((g) => g.key !== key)), []);

  // Words stay unbroken; lines may only wrap at spaces.
  const words: Glyph[][] = [];
  for (const g of glyphs) {
    if (g.char === " " || !words.length || words[words.length - 1][0]?.char === " ") words.push([g]);
    else words[words.length - 1].push(g);
  }

  return (
    <span ref={rootRef} className={`relative inline-block ${className}`} {...props}>
      <span className="sr-only">{text}</span>
      {words.map((word, w) => (
        <span key={w} aria-hidden="true" className="inline-block whitespace-pre">
          {word.map((g) => (
            <span
              key={g.key}
              ref={(el) => {
                if (el) els.current.set(g.key, el);
                else els.current.delete(g.key);
              }}
              className="inline-block"
            >
              {g.char}
            </span>
          ))}
        </span>
      ))}
      {leaving.map((g) => (
        <LeavingGlyph key={`out-${g.key}`} glyph={g} lift={-rise} duration={duration} onDone={done} />
      ))}
    </span>
  );
}

Registry item: /r/text-morph.json