Shared letters slide into their new places while the width springs.
Text & contentmotion
01Preview
Q3 pricing update
Edited by Maya Chen · 4 min ago
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion03Usage
import { TextMorph } from "@/components/ui/text-morph";
<button onClick={publish}>
<TextMorph>{publishing ? "Publishing" : "Publish"}</TextMorph>
</button>
// A status that should be announced when it changes.
<TextMorph aria-live="polite">{status}</TextMorph>04Source
"use client";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
/**
* Stable keys per character: the nth "e" in one string is the nth "e" in the
* next, so shared letters are the same element and glide to their new place.
*/
export function morphKeys(text: string) {
const seen = new Map<string, number>();
return Array.from(text).map((char) => {
const n = seen.get(char) ?? 0;
seen.set(char, n + 1);
return { char, key: `${char === " " ? "space" : char}-${n}` };
});
}
export type TextMorphProps = Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> & {
/** The current text. Change it and shared characters move into place. Single line. */
children: string;
/** Animate the width too, so a button or pill around it resizes smoothly instead of snapping. */
animateWidth?: boolean;
ref?: React.Ref<HTMLSpanElement>;
};
/**
* Morphs between two strings: letters both share slide to where they sit in
* the new word, the rest blur out and in. For labels that change in place,
* "Deploy" to "Deploying", "Draft" to "Published", "Follow" to "Following".
*/
export function TextMorph({ children, animateWidth = true, className, style, ref, ...rest }: TextMorphProps) {
const reduce = useReducedMotion();
const self = useRef<HTMLSpanElement>(null);
const inner = useRef<HTMLSpanElement>(null);
// "auto" until measured, so the server render and first paint are the plain width.
const width = useMotionValue<number | "auto">("auto");
const chars = morphKeys(children);
// Follow the natural width of the string: the first measurement is applied as
// is, every change after it springs, so the button around it resizes smoothly.
useEffect(() => {
const el = inner.current;
if (!el || !animateWidth) return;
let running: ReturnType<typeof animate> | undefined;
const ro = new ResizeObserver(() => {
const next = el.offsetWidth;
const now = width.get();
if (now === "auto" || reduce) return width.set(next);
if (now === next) return;
running?.stop();
// While the width is moving, the trailing edge feathers so letters are
// unveiled (or tucked away) softly instead of drawing over the padding.
self.current?.setAttribute("data-resizing", "");
const current = animate(width, next, spring.soft);
running = current;
current.then(() => {
if (running === current) self.current?.removeAttribute("data-resizing");
});
});
ro.observe(el);
return () => {
ro.disconnect();
running?.stop();
width.set("auto");
};
}, [animateWidth, reduce, width]);
const move = reduce ? { duration: 0 } : spring.soft;
return (
<motion.span
ref={(node: HTMLSpanElement | null) => {
self.current = node;
if (typeof ref === "function") return ref(node);
if (ref) (ref as React.RefObject<HTMLSpanElement | null>).current = node;
}}
data-text-morph=""
style={{ ...style, width }}
className={cn(
"relative inline-block whitespace-nowrap align-bottom",
"data-resizing:mask-r-from-[calc(100%-0.6em)] data-resizing:mask-r-to-100%",
className,
)}
{...(rest as React.ComponentProps<typeof motion.span>)}
>
<span className="sr-only">{children}</span>
<span ref={inner} aria-hidden className="relative inline-flex w-max">
<AnimatePresence mode="popLayout" initial={false}>
{chars.map(({ char, key }) => (
<motion.span
key={key}
layout={reduce ? false : "position"}
initial={reduce ? { opacity: 0 } : { opacity: 0, filter: "blur(3px)", scale: 0.85 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, filter: "blur(3px)", scale: 0.85, transition: { duration: 0.14, ease: ease.in } }}
transition={{
layout: move,
// New letters arrive a beat after the shared ones start moving, so the eye follows the move first.
default: reduce ? { duration: 0.15 } : { duration: 0.26, ease: ease.out, delay: 0.06 },
}}
className="inline-block whitespace-pre"
>
{char}
</motion.span>
))}
</AnimatePresence>
</span>
</motion.span>
);
}05Props
TextMorph
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | string | — | The current text, on one line. Change it and shared characters move into place. |
| animateWidth | boolean | true | Spring the width to the new string so the button or pill around it resizes smoothly. False leaves the width to layout. |
morphKeys
| Prop | Type | Default | Description |
|---|---|---|---|
| text* | string | — | Returns { char, key }[]: the nth occurrence of a character keeps the same key across strings. For building your own morph. |
06Notes
Behavior
- Characters are matched by occurrence, so the second e in one word is the second e in the next: Publish, Publishing, Published and Unpublish share every letter of "ublish".
- The wrapper measures the new string and springs to it, so a button grows or shrinks with its label instead of snapping; while the width moves, the trailing edge feathers so letters are unveiled rather than drawn over the padding.
- Interrupting mid-morph retargets from wherever each letter is. Letters leaving are lifted out of the flow at once, so the new word lays out immediately.
- Built for short single-line labels. Per-letter boxes drop kerning pairs, which is invisible at label sizes and noticeable in long headings.
Motion
- Shared letters move on the soft spring (260 stiffness, 28 damping); the width follows the same spring so both land together.
- New letters arrive 60ms later over 260ms, from 0.85 scale and a 3px blur; leaving letters go in 140ms on the exit ease.
- Reduced motion drops the travel, blur and width spring: letters crossfade in 150ms and the width snaps.
Accessibility
- The real string is visually hidden text inside the wrapper; the per-letter spans are aria-hidden, so it is read as a word, not letters.
- Add aria-live="polite" for statuses that change on their own; the hidden text changes once per update, so it is announced once.
- Inside a button the label stays the button's accessible name and updates with it.