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.jsonOverview
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
- Status buttonSave, Saving, Saved: the spinner opens in, the check draws itself, errors shake, and the label morphs between them.
- Waitlist fieldAn email field that becomes its own answer: the button stretches across it, then settles as "You’re on the list · #1,248".
- Code inputThe six boxes, done properly: digits rise in, a paste cascades, a wrong code shakes and clears, the right one becomes a pill with a check.
- Steps formA multi-step form in one card that changes shape: steps slide in from the way you’re going, checks shake and explain, and Create turns into what comes next.
- Date range pickerA range is one liquid band: it follows your pointer between days, presets flow it to their span, and the dates morph as it moves.
- TestimonialsOne quote, chosen by its people: faces step forward, the words cross-fade through a blur, a ring keeps time.
- Command palette⌘K that grows out of the search pill: fuzzy search, nested lists, and commands that run in place.
- Toast stackNotifications that stack, fan out on hover, follow a promise in one toast, and swipe away.
- Live activityOne pill for whatever is happening now: it updates in place, tap it and it grows into a panel, done blurs into a check.
- Live mapThe world as dots, lighting up where things happen: pings ripple, the light lingers, and one card follows the newest event from city to city.
- Theme toggleThe sun sets into a moon, and the new theme spreads from the button in a circle.
- Pricing calculatorDrag to your size and watch the price roll, the plan morph and, at the top, “Let’s talk”.
- Onboarding checklistThe first five things, ticked off in place: a ring that fills, checks that draw, and a finish worth seeing.
- Changelog timelineWhat shipped, drawn as you read: a rail that fills, dots that light, filters that fold the rest away.
- Changelog traceWhat shipped, routed like a circuit: the line steps aside between releases, and a bead runs down it as you read.
- Changelog scrubberYour release history on a ruler you scrub: drag the playhead, or press play and watch the product ship.
- Cookie bannerAsks once, plainly, then folds into a small cookie button in the corner that grows back into the preferences.
- Newsletter footerA footer that’s still alive: a signup that becomes its own confirmation, drawn link underlines and your name in outline.
- Agent composerThe taller composer for agent surfaces: branch, folder and context on a tab above, permissions and model as pills that grow into their lists.
- Mentions and commandsType @ to pull in context or / for a command. The menu grows from the caret and your text becomes a chip.
- Attachment chipA file on its way into a prompt: uploading, being read, ready or failed.
- Mode switcherFast, Thinking, Research: a segmented switch with a sliding pill and locked paid modes.
- Model pickerThe model pill in your composer grows into the menu: what each model is for, speed, smarts and thinking effort.
- Voice inputDictate a prompt: a waveform from your real mic level, a timer, then transcribing.
- Thinking indicatorFour quiet loaders for the gap before the first token, with a label, a rolling timer and a finish that draws itself.
- Thinking traceAn expandable record of what an agent did: steps, reasoning, search or tool calls.
- Tool callOne tool invocation from arguments to result: preparing, running, done, failed or denied.
- Clarifying questionThe agent pauses to ask. Pick with a click or a number key, or type your own; it folds into a one-line receipt.
- Approval cardThe agent asks before it acts: what it wants to do, the details, Approve or Deny.
- Web researchWatch the agent search: the query types in, page icons stack up as it reads, then it folds into a trail of searches and the pages each one found.
- Command outputA command the agent runs: the log streams in, folds away on success, and stays open on failure.
- Task progressFor work that takes minutes: phases, a live timer, stats, cancel, and the result when it’s done.
- Task logWhat the agent did, task by task, as it does it: steps arrive on a hairline trail, files sit in chips, titles turn past tense.
- Code blockCode in an answer: highlighted as it streams, copy and wrap, long files folded, and an optional Apply.
- Inline rewriteDrag a dial and the paragraph rewrites itself in place: shared words glide, the rest blur out and rise in.
- Diff reviewAccept or reject what the AI changed, word by word for prose or line by line for code.
- Action receiptsWhat the agent actually did, one quiet line each, with Undo while a ring drains. Several stack into one.
- Error and limit statesCalm, specific notices for failed answers, being offline, rate limits and chats that grow too long.
- Usage meterCredits, messages or context left: quiet when there’s plenty, clear before it runs out.
- Jump to latestA chat that follows the answer as it streams, lets go the moment you scroll up, and brings you back.
- Earnings chartA bar chart that changes instead of redrawing: bars glide between periods, the scale and the total roll, a readout follows the pointer.
- Steps chartA week against a daily goal: days that make it fill in, a wave runs the way you step through the weeks, the totals roll.
- Revenue chartThis year as an area, last year behind it; point at a month and the header becomes the readout, switch the measure and the lines reshape.
- Area chartSeveral series stacked, overlapping or as shares: every band morphs between layouts, and the tiles are the legend, the switches and the readout.
- Combo chartEvery day as a bar and the trend as a line on one scale: point at a day and the header becomes its readout while the line’s dot pings where it lands.
- Activity ringsThree daily goals as concentric rings beside a tight legend, with the week underneath: pick a day and the rings sweep to it.
In the templates
See it at work in a whole page. The templates ship it in components/hairline/.
FootnoteSign-up · AI writing tool landing page
CutroomPricing calculator · Video editing studio landing page
HalftonePricing · Developer tool and API landing page
EmberlinePricing · AI SaaS landing page
InfluencePricing · Short-form video agency landing page
ParleySign-up · AI support agent landing page
KeptSign-up · Invoicing and fintech landing page
StillformShot selector · Product photography studio landing page
PrismPricing · AI creative app landing page
TempoPricing · Mobile app launch landing page
RelayPricing · Personal assistant and AI companion landing page
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
| Prop | Type | Description |
|---|---|---|
| children | string | The text. Change it and it morphs. |
| duration | numberdefault 460 | Morph time in ms. |
| direction | "up" | "down"default "up" | New letters rise from below ("up") or drop from above ("down"). |
| animateWidth | booleandefault true | Ease 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