# Word sequence

> A sentence choreographed word by word.

- Type: Component (kinetic)
- Page: https://textlab.dev/components/word-sequence
- Markdown: https://textlab.dev/components/word-sequence.md
- Registry item: https://textlab.dev/r/word-sequence.json
- Source file: `registry/textlab/word-sequence/word-sequence.tsx`
- Dependencies: motion
- Variants: stack, stage, cut
- Keywords: kinetic typography, sequence, title card, storytelling, word by word

## When to use

- Manifestos and intro sequences.
- Title cards for video-like landing pages.

## When not to use

- Navigation or interface copy.
- Sentences over about ten words.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/word-sequence
pnpm dlx shadcn@latest add @textlab/word-sequence
```

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/word-sequence.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 { WordSequence } from "@/components/textlab/word-sequence/word-sequence";

export function Example() {
  return <WordSequence text="Every word gets its moment" variant="stage" />;
}
```

## API reference

### WordSequence

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"stack" \| "stage" \| "cut"` | `"stack"` | Build the sentence, spotlight each word, or hard-cut between scales. |
| `interval` | `number` | `0.45` | Seconds per word. |
| `hold` | `number` | `2` | Seconds the finished sentence holds before looping. |
| `loop` | `boolean` | `true` | Replay after the hold. |
| `as` | `ElementType` | `"div"` | 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 whole sentence is read once from a hidden copy.
- Every sequence ends on the full sentence, readable at rest.

## Motion

- Stack eases each word up into place; stage blurs words in and out; cut uses hard cuts with no tween.

## Reduced motion

The sentence appears whole and still.

## Performance

- One timer; one or a handful of animated spans.

## 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: stack, stage, cut.

## Related

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

## Also in kinetic

- [Echo text](https://textlab.dev/components/echo-text.md): Every change leaves fading echoes behind.

## Source

### components/textlab/word-sequence/word-sequence.tsx

```tsx
"use client";

import { useEffect, 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 { splitWords } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger } from "../lib/use-trigger";

export type WordSequenceVariant = "stage" | "stack" | "cut";

export interface WordSequenceProps {
  text: string;
  /** stage gives each word the spotlight in turn, stack builds the sentence word by word, cut jumps between scales like film titles. */
  variant?: WordSequenceVariant;
  /** Seconds per word. */
  interval?: number;
  /** Seconds to hold the finished sentence before looping. */
  hold?: number;
  loop?: boolean;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

const CUT_SCALES = [1, 0.55, 1.35, 0.8, 1.15];

/**
 * A sentence choreographed word by word, then shown whole. Each variant is a different director:
 * a spotlight, a builder, and a film editor.
 */
export function WordSequence({ text, variant = "stack", interval = 0.45, hold = 2, loop = true, as: Tag = "div", reducedMotion = "user", className, style }: WordSequenceProps) {
  const { ref, on } = useTrigger<HTMLElement>({ trigger: "inView" });
  const reduced = useReduced(reducedMotion);
  const words = splitWords(text).map((w) => w.text);
  const [step, setStep] = useState(0);

  useEffect(() => {
    if (!on || reduced) return;
    const done = step >= words.length;
    if (done && !loop) return;
    const id = setTimeout(() => setStep((s) => (s >= words.length ? 0 : s + 1)), (done ? hold : interval) * 1000);
    return () => clearTimeout(id);
  }, [on, reduced, step, words.length, interval, hold, loop]);

  if (reduced) {
    return (
      <Tag ref={ref} className={className} style={style}>
        {text}
      </Tag>
    );
  }

  const finished = step >= words.length;
  const current = Math.min(step, words.length - 1);

  return (
    <Tag ref={ref} className={className} style={{ position: "relative", display: "grid", placeItems: "center", ...style }}>
      <span style={srOnly}>{text}</span>
      {variant === "stack" ? (
        <span aria-hidden="true" style={{ gridArea: "1 / 1", textAlign: "center" }}>
          {words.map((word, i) => (
            <motion.span
              key={i}
              style={{ display: "inline-block", marginRight: "0.25em" }}
              initial={false}
              animate={i < step || finished ? { opacity: 1, transform: "translateY(0em)", filter: "blur(0px)" } : { opacity: 0, transform: "translateY(0.4em)", filter: "blur(6px)" }}
              transition={{ duration: 0.4, ease: [...ease.enter] }}
            >
              {word}
            </motion.span>
          ))}
        </span>
      ) : (
        <>
          {/* The full sentence holds the space and appears at the end of the sequence. */}
          <motion.span aria-hidden="true" style={{ gridArea: "1 / 1", textAlign: "center" }} initial={false} animate={{ opacity: finished ? 1 : 0, transform: finished ? "scale(1)" : "scale(0.96)" }} transition={{ duration: 0.35, ease: [...ease.enter] }}>
            {text}
          </motion.span>
          <AnimatePresence mode="popLayout">
            {!finished && (
              <motion.span
                key={current}
                aria-hidden="true"
                style={{ gridArea: "1 / 1", whiteSpace: "nowrap", fontSize: variant === "stage" ? "1.6em" : undefined }}
                initial={variant === "cut" ? { opacity: 1, transform: `scale(${CUT_SCALES[current % CUT_SCALES.length]})` } : { opacity: 0, transform: "scale(0.85)", filter: "blur(8px)" }}
                animate={{ opacity: 1, transform: `scale(${variant === "cut" ? CUT_SCALES[current % CUT_SCALES.length] : 1})`, filter: "blur(0px)" }}
                exit={variant === "cut" ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transform: "scale(1.1)", filter: "blur(8px)", transition: { duration: 0.15 } }}
                transition={variant === "cut" ? { duration: 0 } : { duration: 0.25, ease: [...ease.enter] }}
              >
                {words[current]}
              </motion.span>
            )}
          </AnimatePresence>
        </>
      )}
    </Tag>
  );
}

export default WordSequence;
```

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