# Recast — Animated Text Effect for React

> A word that re-forms into a new one: shared letters slide along the baseline to their new home while unique letters iris in and out, then tighten a hair into their final kerning. Real selectable text, announced politely on change; swaps instantly under reduced motion.

- Page: https://crucibleui.com/components/text/recast
- Category: [text](https://crucibleui.com/components/text)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/recast
```

Registry JSON: https://crucibleui.com/r/recast.json

Import from `@/components/crucible/recast`.

## Props (`RecastProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children?` | `string` | — | The text to display. Whenever it changes, the word re-forms: shared letters slide to their new home while unique letters iris in/out. |
| `words?` | `string[]` | — | Optional words to auto-cycle through. When provided, the component morphs between them on a timer (pausing offscreen and on hidden tabs) and `children` is ignored. |
| `interval?` | `number` | `2600` | Milliseconds each cycled word holds before re-forming into the next. |
| `loop?` | `boolean` | `true` | Restart from the first word after the last (cycle mode). |
| `paused?` | `boolean` | `false` | Freeze cycling on the current word. |
| `duration?` | `number` | `0.6` | Seconds the position morph / iris takes. |
| `stagger?` | `number` | `0.016` | Seconds between each entering/leaving letter's start. |
| `onIndexChange?` | `(index: number) => void` | — | Fired whenever a new word becomes current (cycle mode). |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"span">, "children">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Recast } from "@/components/crucible/recast";

export default function RecastDemo() {
  const [saved, setSaved] = React.useState(false);

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.035),transparent_70%)]"
      />
      <div className="relative z-10 flex flex-col items-center gap-9 px-6 text-center">
        <div className="flex flex-col items-center gap-3">
          <h2 className="text-5xl font-semibold tracking-tight text-white">
            <Recast words={["Continue", "Confirm", "Connect", "Complete"]} />
          </h2>
          <p className="text-sm text-neutral-500">
            Shared letters slide to their new home; the rest iris in and out.
          </p>
        </div>

        <div className="flex flex-col items-center gap-3">
          <span className="text-2xl font-medium text-neutral-300">
            <Recast words={["Saving", "Saved", "Synced"]} interval={2000} duration={0.5} />
          </span>
          <span className="text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-600">
            A status word, re-forming
          </span>
        </div>

        <div className="flex flex-col items-center gap-2">
          <button
            type="button"
            onClick={() => setSaved((s) => !s)}
            className="rounded-full border border-white/10 bg-white/5 px-5 py-2 text-base font-medium text-neutral-100 transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/70"
          >
            <Recast>{saved ? "Saved" : "Save"}</Recast>
          </button>
          <span className="text-xs text-neutral-500">Click to morph the label</span>
        </div>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`recast.tsx`)

```tsx
"use client";

import { AnimatePresence, motion, useAnimationControls, type Transition } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

export interface RecastProps extends Omit<React.ComponentPropsWithoutRef<"span">, "children"> {
  /**
   * The text to display. Whenever it changes, the word re-forms: shared letters
   * slide to their new home while unique letters iris in/out.
   */
  children?: string;
  /**
   * Optional words to auto-cycle through. When provided, the component morphs
   * between them on a timer (pausing offscreen and on hidden tabs) and
   * `children` is ignored.
   */
  words?: string[];
  /** Milliseconds each cycled word holds before re-forming into the next. @default 2600 */
  interval?: number;
  /** Restart from the first word after the last (cycle mode). @default true */
  loop?: boolean;
  /** Freeze cycling on the current word. @default false */
  paused?: boolean;
  /** Seconds the position morph / iris takes. @default 0.6 */
  duration?: number;
  /** Seconds between each entering/leaving letter's start. @default 0.016 */
  stagger?: number;
  /** Fired whenever a new word becomes current (cycle mode). */
  onIndexChange?: (index: number) => void;
}

/** Occurrence-indexed keys so shared letters keep a stable identity across a
 *  morph (the second "n" in "Continue" stays the second "n"), which is what
 *  lets motion's layout engine slide them from their old slot to the new one
 *  instead of cross-fading. Letters with no match iris in/out. */
function keyLetters(text: string): { key: string; char: string }[] {
  const counts = new Map<string, number>();
  const out: { key: string; char: string }[] = [];
  for (const char of Array.from(text)) {
    const n = counts.get(char) ?? 0;
    counts.set(char, n + 1);
    out.push({ key: `${char} ${n}`, char });
  }
  return out;
}

/** Split into runs of word (non-space) vs. whitespace so the line only ever
 *  breaks at spaces — each word is its own nowrap group, never split mid-word. */
function tokenize(text: string): { text: string; isSpace: boolean }[] {
  const chars = Array.from(text);
  const tokens: { text: string; isSpace: boolean }[] = [];
  let i = 0;
  while (i < chars.length) {
    const isSpace = /\s/.test(chars[i]!);
    let j = i;
    while (j < chars.length && /\s/.test(chars[j]!) === isSpace) j++;
    tokens.push({ text: chars.slice(i, j).join(""), isSpace });
    i = j;
  }
  return tokens;
}

/** One word slot. Owns its own kerning-settle so a phrase's words settle
 *  independently. Letters are keyed by identity; motion's `layout` FLIPs the
 *  survivors along the baseline while `AnimatePresence` iris-transitions the
 *  rest. Shared letters never touch opacity, so they stay fully legible. */
function WordMorph({
  text,
  duration,
  stagger,
}: {
  text: string;
  duration: number;
  stagger: number;
}) {
  const controls = useAnimationControls();
  const letters = React.useMemo(() => keyLetters(text), [text]);
  const isFirst = React.useRef(true);

  // Kerning settle: once the letters have slid home, the word tightens by a
  // hair into its final rest, like type seating in a form. Driven as a
  // scaleX overshoot on the whole slot — a transform, so it never fights the
  // letters' layout measurement. Skipped on the initial word.
  React.useEffect(() => {
    if (isFirst.current) {
      isFirst.current = false;
      return;
    }
    void controls.start(
      { scaleX: [1.03, 1] },
      { duration: duration * 0.8, delay: duration * 0.45, ease: [0.22, 1, 0.36, 1] }
    );
  }, [text, duration, controls]);

  const positionTransition: Transition = { type: "spring", duration, bounce: 0.16 };

  return (
    <motion.span
      animate={controls}
      className="relative inline-block whitespace-nowrap align-baseline will-change-transform"
      style={{ transformOrigin: "50% 60%" }}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        {letters.map(({ key, char }, i) => (
          <motion.span
            key={key}
            layout
            initial={{ opacity: 0, scale: 0.28 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.28 }}
            transition={{
              layout: positionTransition,
              opacity: { duration: duration * 0.5, delay: i * stagger, ease: [0.4, 0, 0.2, 1] },
              scale: { type: "spring", duration: duration * 0.7, bounce: 0.32, delay: i * stagger },
            }}
            className="inline-block whitespace-pre align-baseline will-change-[transform,opacity]"
          >
            {char}
          </motion.span>
        ))}
      </AnimatePresence>
    </motion.span>
  );
}

/**
 * Recast — a word that re-forms into a new one. When the text changes, letters
 * shared with the previous word slide along the baseline to their new home
 * while unique letters iris in and out, so the surviving letters stay legible
 * the whole way through (no blur, no scramble, no flash). The letters tighten a
 * hair into their final kerning once they arrive. Neutral monochrome — it
 * inherits the surrounding text color. The first word server-renders as plain
 * text; reduced motion swaps the text instantly.
 */
export function Recast({
  children = "",
  words,
  interval = 2600,
  loop = true,
  paused = false,
  duration = 0.6,
  stagger = 0.016,
  onIndexChange,
  className,
  ...props
}: RecastProps) {
  const reducedMotion = useReducedMotion();
  const cycling = Array.isArray(words) && words.length > 0;

  const [index, setIndex] = React.useState(0);
  const [mounted, setMounted] = React.useState(false);
  const [hidden, setHidden] = React.useState(false);
  const [inView, setInView] = React.useState(true);
  const wrapperRef = React.useRef<HTMLSpanElement>(null);

  const safeIndex = cycling ? Math.max(0, Math.min(index, words!.length - 1)) : 0;
  const currentText = cycling ? words![safeIndex] ?? "" : children;

  React.useEffect(() => {
    setMounted(true);
  }, []);

  // Pause cycling on hidden tabs and offscreen — nothing runs at rest.
  React.useEffect(() => {
    if (!cycling) return;
    const onVisibility = () => setHidden(document.hidden);
    document.addEventListener("visibilitychange", onVisibility);
    return () => document.removeEventListener("visibilitychange", onVisibility);
  }, [cycling]);

  React.useEffect(() => {
    if (!cycling) return;
    const node = wrapperRef.current;
    if (!node || typeof IntersectionObserver === "undefined") return;
    const observer = new IntersectionObserver(([entry]) => {
      setInView(entry?.isIntersecting ?? true);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [cycling]);

  React.useEffect(() => {
    if (!cycling || paused || hidden || !inView || words!.length < 2) return;
    if (!loop && safeIndex === words!.length - 1) return;
    const id = window.setTimeout(() => {
      setIndex((i) => (i + 1) % words!.length);
    }, interval);
    return () => window.clearTimeout(id);
    // `words.length`, not `words`: an inline array literal is a new identity on
    // every parent render and would otherwise restart the hold timer each time.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cycling, safeIndex, paused, hidden, inView, loop, interval, words?.length]);

  const onIndexChangeRef = React.useRef(onIndexChange);
  React.useEffect(() => {
    onIndexChangeRef.current = onIndexChange;
  });
  const isFirstIndex = React.useRef(true);
  React.useEffect(() => {
    if (isFirstIndex.current) {
      isFirstIndex.current = false;
      return;
    }
    onIndexChangeRef.current?.(safeIndex);
  }, [safeIndex]);

  // Before mount and under reduced motion: plain, static, selectable text —
  // matches SSR exactly (no hydration mismatch) and never animates the first word.
  const showMorph = mounted && !reducedMotion;

  const tokens = React.useMemo(() => (showMorph ? tokenize(currentText) : []), [showMorph, currentText]);
  let wordSlot = 0;

  return (
    <span
      ref={wrapperRef}
      className={cn("inline-block align-baseline", className)}
      {...props}
    >
      {/* Polite live region announces the current word on every change. */}
      <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {currentText}
      </span>

      {showMorph ? (
        <span aria-hidden>
          {tokens.map((token, i) =>
            token.isSpace ? (
              <span key={`s-${i}`} className="whitespace-pre">
                {token.text}
              </span>
            ) : (
              <WordMorph
                key={`w-${wordSlot++}`}
                text={token.text}
                duration={duration}
                stagger={stagger}
              />
            )
          )}
        </span>
      ) : (
        <span aria-hidden>{currentText}</span>
      )}
    </span>
  );
}
```
