reelcn
GitHubBack to landing

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
TextMorph, phrase thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/text-morph.json

Files 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>
  );
}

On this page