Text and numbers
Text morph
Text that changes letter by letter: shared letters slide to their new place and only the new letters animate, so the reader sees what changed.
Live preview
Loading preview
A label that cycles through short words that share letters, such as Unsaved, Saved and Saving, with a Replay button that steps through them again.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add text-morphUsage
The demo above, as code.
demo.tsx
'use client';
import * as React from 'react';
import { Button } from '../button/button';
import { TextMorph } from './text-morph';
const WORDS = ['Unsaved', 'Saving', 'Saved'] as const;
// Replay steps through the words with these start times (ms).
const STEPS = [0, 900, 1900];
export default function TextMorphDemo({ replay = 0 }: { replay?: number }) {
const [word, setWord] = React.useState<string>('Saved');
React.useEffect(() => {
if (replay === 0) return;
const timers = STEPS.map((at, i) => window.setTimeout(() => setWord(WORDS[i] as string), at));
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div className="flex flex-col items-center gap-6">
<p className="text-display-sm font-semibold tracking-tight text-primary">
<TextMorph data-testid="morph" text={word} />
</p>
<fieldset className="flex items-center gap-1 rounded-lg border border-secondary bg-primary p-1">
<legend className="sr-only">Pick a word</legend>
{WORDS.map((w) => (
<Button
key={w}
size="sm"
hierarchy="tertiary"
aria-pressed={word === w}
className={word === w ? 'bg-secondary text-primary' : undefined}
onClick={() => setWord(w)}
>
{w}
</Button>
))}
</fieldset>
</div>
);
}
API
Parts: root (inline box), letters, leaving letters (absolute layer), screen reader text.
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | - | The text to show. Changing it starts the morph. |
| direction | "up" | "down" | "up" | New letters rise from below (up) or drop from above (down). |
| animateWidth | boolean | true | Ease the box width to the new text. Turn off for text that wraps. |
| duration | number | 460 | Morph time in ms. Defaults to duration-3xl. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| TM1 | Text changes | shared letters | position | old place → new place | duration-3xl (460)ease-out | Moves at once |
| TM2 | Text changes | new letters | opacity, blur, translate y | 0, blur-text, 0.35em → 1, 0, 0 | duration-3xl (460)ease-outdelay stagger-text (16) | Shows at once |
| TM3 | Text changes | leaving letters | opacity, blur, translate y | 1, 0, 0 → 0, blur-text, -0.35em | duration-md (260)ease-out | Hides at once |
| TM4 | Text changes | root | width | old width → new width | duration-3xl (460)ease-out | Resizes at once |
| TM5 | Less than half of the letters are shared | whole text | crossfade | all old letters leave → all new letters enter | duration-3xl (460)ease-outdelay stagger-text (16) | Swaps at once |
Accessibility
- The full text is always in the DOM for screen readers; the animated letters are aria-hidden.
- The component does not announce changes itself; wrap it in a live region when the change must be announced.