# Text morph

> One word turns into the next, letter by letter.

- Type: Component (typing)
- Page: https://textlab.dev/components/text-morph
- Markdown: https://textlab.dev/components/text-morph.md
- Registry item: https://textlab.dev/r/text-morph.json
- Source file: `registry/textlab/text-morph/text-morph.tsx`
- Dependencies: motion
- Variants: slide, blur, flip
- Keywords: morph, label, flip, layout animation, button state, text transition

## When to use

- Button labels that change state: Save to Saved, Follow to Following.
- Toggles and short status words.

## When not to use

- Long sentences; most letters change, so the morph reads as noise.
- Labels that change many times a second.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/text-morph
pnpm dlx shadcn@latest add @textlab/text-morph
```

The `@textlab` name needs `"registries": { "@textlab": "https://textlab.dev/r/{name}.json" }` in `components.json`. Without it, use the full URL:

```bash
npx shadcn@latest add https://textlab.dev/r/text-morph.json
```

Import the tokens once in your root layout: `import "@/components/textlab/tokens.css";`

### Manual

1. Install the dependencies: `npm install motion@12`
2. Copy `text-fx` (https://textlab.dev/r/text-fx.json) and this component into `components/textlab/`.

## Usage

```tsx
import { TextMorph } from "@/components/textlab/text-morph/text-morph";

export function Example() {
  return <button onClick={save}><TextMorph text={saved ? "Saved" : "Save"} /></button>;
}
```

## API reference

### TextMorph

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"slide" \| "blur" \| "flip"` | `"slide"` | How new letters arrive and old ones leave. Shared letters always glide. |
| `live` | `boolean` | `true` | Announce changes politely to screen readers. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |

## Accessibility

- The new label is announced politely by default.
- Letters are aria-hidden; screen readers never hear them one by one.

## Motion

- Shared letters glide on a smooth spring and can be interrupted mid-morph.
- Exits are quick (150 ms) so the new word never waits.

## Reduced motion

The label swaps instantly.

## Performance

- Layout animation per letter; keep labels short.

## Notes for AI

- Use only the props documented above. Do not invent props.
- Keep the screen-reader text and the reduced-motion branch when editing the source.
- Pick a variant from: slide, blur, flip.

## Related

- [Word rotator](https://textlab.dev/components/word-rotator.md): Cycles words while the width follows.
- [Typewriter](https://textlab.dev/components/typewriter.md): Types, deletes and retypes with a caret.
- [Flip roll](https://textlab.dev/components/flip-roll.md): Hover and the label rolls up into a fresh copy.

## Also in typing

- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.
- [Hover scramble](https://textlab.dev/components/hover-scramble.md): Scrambles on hover, settles on leave.
- [Matrix decode](https://textlab.dev/components/matrix-decode.md): Glyph rain resolves into the word.
- [Caret morph](https://textlab.dev/components/caret-morph.md): A caret that glides between words and reshapes to fit.

## Source

### components/textlab/text-morph/text-morph.tsx

```tsx
"use client";

import type { CSSProperties, ElementType } from "react";
import { AnimatePresence, motion, type TargetAndTransition } from "motion/react";
import { SrText } from "../lib/a11y";
import { spring } from "../lib/motion-tokens";
import { graphemes } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";

export type TextMorphVariant = "slide" | "blur" | "flip";

export interface TextMorphProps {
  /** The current label. Change it and the letters morph. */
  text: string;
  /** How new letters arrive and old ones leave. Shared letters always glide. */
  variant?: TextMorphVariant;
  /** Announce changes to screen readers. */
  live?: boolean;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

const ENTER: Record<TextMorphVariant, { from: TargetAndTransition; exit: TargetAndTransition }> = {
  slide: { from: { opacity: 0, y: "0.35em" }, exit: { opacity: 0, y: "-0.35em" } },
  blur: { from: { opacity: 0, filter: "blur(6px)", scale: 0.9 }, exit: { opacity: 0, filter: "blur(6px)", scale: 0.9 } },
  flip: { from: { opacity: 0, rotateX: -90 }, exit: { opacity: 0, rotateX: 90 } },
};

/** Key each letter by itself and its occurrence, so the second "o" in "loop" stays the second "o". */
function keyed(text: string) {
  const seen = new Map<string, number>();
  return graphemes(text).map((char) => {
    const n = seen.get(char) ?? 0;
    seen.set(char, n + 1);
    return { char, key: `${char}-${n}` };
  });
}

/**
 * One word turns into the next, letter by letter. Letters both words share glide to their new
 * place (FLIP via layout animation); the rest leave and arrive in place.
 */
export function TextMorph({ text, variant = "slide", live = true, as: Tag = "span", reducedMotion = "user", className, style }: TextMorphProps) {
  const reduced = useReduced(reducedMotion);
  if (reduced) {
    return (
      <Tag className={className} style={style} aria-live={live ? "polite" : undefined}>
        {text}
      </Tag>
    );
  }
  const { from, exit } = ENTER[variant];
  return (
    <Tag className={className} style={{ display: "inline-flex", whiteSpace: "pre", ...style }}>
      <SrText>
        <span aria-live={live ? "polite" : undefined}>{text}</span>
      </SrText>
      <motion.span aria-hidden="true" layout style={{ display: "inline-flex", perspective: variant === "flip" ? 400 : undefined }} transition={spring.smooth}>
        <AnimatePresence mode="popLayout" initial={false}>
          {keyed(text).map(({ char, key }) => (
            <motion.span
              key={key}
              layout="position"
              initial={from}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)", scale: 1, rotateX: 0 }}
              exit={{ ...exit, transition: { duration: 0.15 } }}
              transition={spring.smooth}
              style={{ display: "inline-block" }}
            >
              {char === " " ? " " : char}
            </motion.span>
          ))}
        </AnimatePresence>
      </motion.span>
    </Tag>
  );
}

export default TextMorph;
```

Full library index: https://textlab.dev/llms.txt
