# Echo text

> Every change leaves fading echoes behind.

- Type: Component (kinetic)
- Page: https://textlab.dev/components/echo-text
- Markdown: https://textlab.dev/components/echo-text.md
- Registry item: https://textlab.dev/r/echo-text.json
- Source file: `registry/textlab/echo-text/echo-text.tsx`
- Dependencies: motion
- Variants: trail, ghost
- Keywords: echo, ghost, trail, afterimage, text change, transition

## When to use

- Status words, scores and mode labels where the change matters.
- Expressive headlines that cycle.

## When not to use

- Values that change many times a second; echoes pile up.
- Dense UI where trailing copies would overlap neighbours.

## Installation

### CLI

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

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/echo-text.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 { EchoText } from "@/components/textlab/echo-text/echo-text";

export function Example() {
  return <EchoText text={status} variant="ghost" />;
}
```

## API reference

### EchoText

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"trail" \| "ghost"` | `"trail"` | A row of offset copies that fade in sequence, or a single bloom outward. |
| `echoes` | `number` | `4` | Copies per change, for trail. |
| `live` | `boolean` | `true` | Announce changes politely. |
| `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 text is announced politely; echoes are aria-hidden.
- Echoes never block pointer events.

## Motion

- Echoes drift down and right, each a beat behind the last, and fade over about 0.7 s.
- Only the last three changes keep echoes, so rapid updates stay clean.

## Reduced motion

The text swaps in place with no echoes.

## Performance

- A few short-lived motion spans per change, removed when they finish.

## 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: trail, ghost.

## Related

- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
- [Word rotator](https://textlab.dev/components/word-rotator.md): Cycles words while the width follows.
- [Chromatic text](https://textlab.dev/components/chromatic-text.md): The color channels split and rejoin.

## Also in kinetic

- [Kinetic beat](https://textlab.dev/components/kinetic-beat.md): Words hit on a beat.
- [Word sequence](https://textlab.dev/components/word-sequence.md): A sentence choreographed word by word.
- [Read along](https://textlab.dev/components/read-along.md): A highlight follows the reading, word by word.

## Source

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

```tsx
"use client";

import { useState, type CSSProperties, type ElementType } from "react";
import { AnimatePresence, motion } from "motion/react";
import { srOnly } from "../lib/a11y";
import { ease } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";

export type EchoTextVariant = "trail" | "ghost";

export interface EchoTextProps {
  /** The current text. Each change leaves echoes of the old one. */
  text: string;
  /** trail drops a row of offset copies that fade in sequence; ghost blooms the old text outward as it fades. */
  variant?: EchoTextVariant;
  /** Number of echo copies per change. */
  echoes?: number;
  /** Announce changes to screen readers. */
  live?: boolean;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

/**
 * Every change leaves fading echoes behind. The new text arrives crisp while copies of the old one
 * drift off and dissolve, so the change itself is visible for a moment.
 */
export function EchoText({ text, variant = "trail", echoes = 4, live = true, as: Tag = "span", reducedMotion = "user", className, style }: EchoTextProps) {
  const reduced = useReduced(reducedMotion);
  const [history, setHistory] = useState<{ id: number; text: string }[]>([]);
  const [previous, setPrevious] = useState(text);
  const [count, setCount] = useState(0);

  // Track changes while rendering, the way React recommends for state that follows a prop.
  if (text !== previous) {
    setPrevious(text);
    setCount((c) => c + 1);
    setHistory((h) => [...h.slice(-2), { id: count + 1, text: previous }]);
  }

  if (reduced) {
    return (
      <Tag className={className} style={style} aria-live={live ? "polite" : undefined}>
        {text}
      </Tag>
    );
  }

  return (
    <Tag className={className} style={{ position: "relative", display: "inline-grid", ...style }}>
      <span style={srOnly}>
        <span aria-live={live ? "polite" : undefined}>{text}</span>
      </span>
      <AnimatePresence>
        {history.map((entry) =>
          Array.from({ length: variant === "trail" ? echoes : 1 }, (_, k) => (
            <motion.span
              key={`${entry.id}-${k}`}
              aria-hidden="true"
              style={{ gridArea: "1 / 1", whiteSpace: "nowrap", pointerEvents: "none" }}
              initial={variant === "trail" ? { opacity: 0.5 - k * 0.08, transform: "translate(0em, 0em)" } : { opacity: 0.7, transform: "scale(1)", filter: "blur(0px)" }}
              animate={
                variant === "trail"
                  ? { opacity: 0, transform: `translate(${(k + 1) * 0.06}em, ${(k + 1) * 0.1}em)` }
                  : { opacity: 0, transform: "scale(1.35)", filter: "blur(6px)" }
              }
              transition={{ duration: 0.7 + k * 0.12, delay: k * 0.05, ease: [...ease.enter] }}
              onAnimationComplete={() => {
                if (k === 0) setHistory((h) => h.filter((e) => e.id !== entry.id));
              }}
            >
              {entry.text}
            </motion.span>
          )),
        )}
      </AnimatePresence>
      <motion.span
        key={count}
        aria-hidden="true"
        style={{ gridArea: "1 / 1", whiteSpace: "nowrap", position: "relative" }}
        initial={count === 0 ? false : { opacity: 0, filter: "blur(4px)" }}
        animate={{ opacity: 1, filter: "blur(0px)" }}
        transition={{ duration: 0.3, ease: [...ease.enter] }}
      >
        {text}
      </motion.span>
    </Tag>
  );
}

export default EchoText;
```

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