Lunia DS

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.

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-morph

Usage

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.

PropTypeDefaultDescription
textstring-The text to show. Changing it starts the morph.
direction"up" | "down""up"New letters rise from below (up) or drop from above (down).
animateWidthbooleantrueEase the box width to the new text. Turn off for text that wraps.
durationnumber460Morph 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
TM1Text changesshared letterspositionold place → new placeduration-3xl (460)ease-outMoves at once
TM2Text changesnew lettersopacity, blur, translate y0, blur-text, 0.35em → 1, 0, 0duration-3xl (460)ease-outdelay stagger-text (16)Shows at once
TM3Text changesleaving lettersopacity, blur, translate y1, 0, 0 → 0, blur-text, -0.35emduration-md (260)ease-outHides at once
TM4Text changesrootwidthold width → new widthduration-3xl (460)ease-outResizes at once
TM5Less than half of the letters are sharedwhole textcrossfadeall old letters leave → all new letters enterduration-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.