Content·10.6
Text Morph
Letters travel to their new word.
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/text-morph.jsonUsage
"use client";
import { useState } from "react";
import { TextMorph } from "@/components/yugo/text-morph";
const TIERS = ["seventeen", "forty-one", "one hundred"];
export function SeatCount() {
const [seats, setSeats] = useState(TIERS[0]);
return (
<div>
<TextMorph
text={seats}
reserve={TIERS}
announce
className="text-[27px] font-medium tracking-[-0.03em]"
/>
{TIERS.map((tier) => (
<button key={tier} type="button" onClick={() => setSeats(tier)}>
{tier}
</button>
))}
</div>
);
}Source
"use client";
import { useEffect, useId, useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
const EASE = [0.23, 1, 0.32, 1] as const;
const LEAVE = [0.4, 0, 1, 1] as const;
const SMALL = {
type: "spring",
stiffness: 700,
damping: 46,
mass: 0.5,
} as const;
const INSTANT = { duration: 0 } as const;
const SETTLE = 400;
const SPACE = " ";
export type TextMorphCharacter = {
/** stable across renders: the nth occurrence of this letter, case-folded */
id: string;
label: string;
};
/**
* Splits a string into characters that keep their identity between two
* strings. The key is the letter plus how many times it has already appeared,
* case-folded, so the "e" in "seven" and the "E" in "Eleven" are the same
* object and travel rather than crossfade.
*/
export function useTextMorph(text: string): TextMorphCharacter[] {
const base = useId();
return useMemo(() => {
const seen: Record<string, number> = {};
return Array.from(text).map((char) => {
const key = char.toLowerCase();
seen[key] = (seen[key] ?? 0) + 1;
return {
id: `${base}-${key}-${seen[key]}`,
label: char === " " ? SPACE : char,
};
});
}, [text, base]);
}
export type TextMorphProps = {
text: string;
as?: React.ElementType;
reserve?: readonly string[];
announce?: boolean;
className?: string;
};
export function TextMorph({
text,
as: Component = "span",
reserve,
announce = false,
className = "",
}: TextMorphProps) {
const reduced = useReducedMotion();
const characters = useTextMorph(text);
const [said, setSaid] = useState("");
// Announce late: a value that morphs through four strings while a number
// ticks should be one sentence, not four. §18
useEffect(() => {
if (!announce) return;
const timer = setTimeout(() => setSaid(text), SETTLE);
return () => clearTimeout(timer);
}, [announce, text]);
return (
<Component className={`inline-grid ${className}`}>
{/* Every string this box can ever hold, drawn invisibly in the same
cell, so the column is already at its widest and the letters morph
inside a box that never moves. */}
{reserve?.map((phrase) => (
<span
key={phrase}
aria-hidden="true"
className="invisible col-start-1 row-start-1 whitespace-pre"
>
{phrase}
</span>
))}
<span
aria-hidden="true"
className="col-start-1 row-start-1 whitespace-pre"
>
{/* Surviving letters keep their key, so they stay mounted and travel
to their new slot with layout="position". No layoutId: a shared id
here means an exiting letter and an arriving one can claim the
same identity mid-flight and pile up. §5i */}
<AnimatePresence mode="popLayout" initial={false}>
{characters.map((character) => (
<motion.span
key={character.id}
layout={reduced ? false : "position"}
className="inline-block"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: reduced
? INSTANT
: { duration: 0.08, ease: LEAVE },
}}
transition={
reduced
? INSTANT
: {
...SMALL,
// Entrants wait for the leavers to clear: 60ms is under
// perception as a delay and over it as a smear.
opacity: { duration: 0.16, ease: EASE, delay: 0.06 },
}
}
>
{character.label}
</motion.span>
))}
</AnimatePresence>
</span>
<span className="sr-only" aria-live={announce ? "polite" : undefined}>
{announce ? said : text}
</span>
</Component>
);
}Props
textstringThe string to display. Changing it morphs from the old one rather than replacing it.
as"span"ElementTypeThe element the box is rendered as, for slotting a morphing value into a heading or a paragraph.
reservereadonly string[]Every string this box will ever hold. Each is drawn invisibly in the same cell, so the column is already at its widest and nothing around it moves.
announcefalsebooleanWhether to speak the settled value through a polite live region, 400ms after the last change.
className""stringAppended last to the outer box.