Text morph
Morphs one phrase into another: shared letters fly to their new position, leftover letters blur in or out instead of a plain cross-fade.
TextMorph, phrase
Installation
npx shadcn add https://www.reelcn.dev/r/text-morph.jsonFiles land in src/reelcn/ with the items it builds on.
Usage
<Center>
<TextMorph from="Build faster" to="Ship faster" />
</Center>Props
PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0.from*string
to*string
sizenumber Default
96.weightnumber
font"mono" | "heading" | "body" Default
"heading".colorstring
arcnumberHow high moving letters arc, in em: rightward ones rise, leftward ones dip, so crossing letters pass. 0 slides straight. Default
0.25.accentColorstring
trailbooleanFaint trailing copies of matched letters as they move. Off by default. Default
false.styleCSSProperties
classNamestring
Built from
Use
- Before/after headlines, "problem → solution" hooks
- A single word swap that should feel connected, not like two separate reveals
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/text-morph.tsx
/**
* @title Text Morph
* @category text
* @description Morphs one phrase into another: shared letters fly to their new position, leftover letters blur in or out instead of a plain cross-fade.
* @duration 36
* @use Before/after headlines, "problem → solution" hooks
* @use A single word swap that should feel connected, not like two separate reveals
* @tags morph, kinetic, headline, grapheme
* @example
* <Center>
* <TextMorph from="Build faster" to="Ship faster" />
* </Center>
*/
import type React from "react";
import { Fragment } from "react";
import {
clamp01,
graphemes,
type MotionProps,
matchGraphemes,
measurePx,
tween,
useMotion,
useTextMetrics,
useTheme,
useViewport,
} from "./core";
export type TextMorphProps = MotionProps & {
from: string;
to: string;
size?: number;
weight?: number;
font?: "heading" | "body" | "mono";
color?: string;
/** How high moving letters arc, in em: rightward ones rise, leftward ones dip, so crossing letters pass. 0 slides straight. */
arc?: number;
accentColor?: string;
/** Faint trailing copies of matched letters as they move. Off by default. */
trail?: boolean;
style?: React.CSSProperties;
className?: string;
};
function graphemeXPositions(chars: string[], font: string): number[] {
let x = 0;
const positions: number[] = [];
for (const char of chars) {
positions.push(x);
x += measurePx(char, font);
}
return positions;
}
export function TextMorph({
from,
to,
size = 96,
weight,
font = "heading",
arc = 0.25,
color,
accentColor,
trail = false,
style,
className,
...motion
}: TextMorphProps) {
const theme = useTheme();
const { u } = useViewport();
const m = useMotion(motion);
const fontPx = u(size);
const fontWeight = weight ?? (font === "heading" ? theme.headingWeight : 500);
const fontString = `${fontWeight} ${fontPx}px ${theme.fonts[font]}`;
const gate = useTextMetrics(`${from}${to}`, {
fontFamily: theme.fonts[font],
fontSize: fontPx,
fontWeight,
});
const fromChars = graphemes(from);
const toChars = graphemes(to);
const fromX = gate.ready ? graphemeXPositions(fromChars, fontString) : fromChars.map(() => 0);
const toX = gate.ready ? graphemeXPositions(toChars, fontString) : toChars.map(() => 0);
const pairs = matchGraphemes(fromChars, toChars);
const widthOf = (chars: string[], xs: number[]) =>
chars.length && gate.ready ? xs[xs.length - 1] + measurePx(chars[chars.length - 1], fontString) : 0;
const fromWidth = widthOf(fromChars, fromX);
const toWidth = widthOf(toChars, toX);
// `progress` keeps the theme's own motion shape (including settle/bouncy overshoot) for the letter
// position lerp below, where an overshoot-then-settle read is the intended personality. `t` is a plain
// linear time fraction across the full `enterFrames` window, independent of motion shape, the blur-in/
// blur-out timing below needs a value that only reaches 1 at the end of the window, not at 40% of it
// (which is what settle's own overshoot curve does, and is what produced the "morph finishes in 2-3
// frames under daylight" bug: settle crosses these formulas' own 0.5/0.7 thresholds within the first
// fifth of the window).
const progress = tween(m.frame, m.fps, { from: m.delay, duration: m.enterFrames, motion: m.preset });
const t = clamp01((m.frame - m.delay) / Math.max(m.enterFrames, 1));
const trailCopies = trail ? [1, 2, 3] : [];
return (
<div
className={className}
style={{
position: "relative",
// Glyphs are absolute, so the box needs an explicit width to be centered by its parent.
width: fromWidth + (toWidth - fromWidth) * progress,
height: fontPx * 1.2,
fontFamily: theme.fonts[font],
fontSize: fontPx,
fontWeight,
color: color ?? theme.colors.foreground,
opacity: gate.ready ? 1 - m.exit : 0,
...style,
}}
>
{pairs.map((pair, key) => {
if (pair.fromIndex >= 0 && pair.toIndex >= 0) {
const fromPos = fromX[pair.fromIndex];
const toPos = toX[pair.toIndex];
const x = fromPos + (toPos - fromPos) * progress;
const lift = Math.sign(fromPos - toPos) * Math.sin(clamp01(progress) * Math.PI) * arc * fontPx;
return (
<Fragment key={key}>
{trailCopies.map((k) => {
const trailProgress = Math.max(progress - k * 0.06, 0);
const trailX = fromPos + (toPos - fromPos) * trailProgress;
return (
<span
key={k}
style={{
position: "absolute",
left: trailX,
top: 0,
display: "inline-block",
whiteSpace: "pre",
opacity: 0.12 / k,
}}
>
{toChars[pair.toIndex]}
</span>
);
})}
<span style={{ position: "absolute", left: x, top: lift, display: "inline-block", whiteSpace: "pre" }}>
{toChars[pair.toIndex]}
</span>
</Fragment>
);
}
if (pair.toIndex >= 0) {
const enter = Math.max(0, Math.min(1, (t - 0.3) / 0.7));
return (
<span
key={key}
style={{
position: "absolute",
left: toX[pair.toIndex],
top: 0,
display: "inline-block",
whiteSpace: "pre",
opacity: enter,
filter: `blur(${(1 - enter) * fontPx * 0.15}px)`,
color: accentColor ?? theme.colors.accent,
}}
>
{toChars[pair.toIndex]}
</span>
);
}
const exitP = Math.max(0, Math.min(1, t / 0.5));
return (
<span
key={key}
style={{
position: "absolute",
left: fromX[pair.fromIndex],
top: 0,
display: "inline-block",
whiteSpace: "pre",
opacity: 1 - exitP,
filter: `blur(${exitP * fontPx * 0.15}px)`,
}}
>
{fromChars[pair.fromIndex]}
</span>
);
})}
</div>
);
}