# Echo Reel: Rotating Text Headline for React

> A giant word printed solid, with the words before and after it in the list stacked above and below as pale halftone proofs. Every few seconds the reel turns one row: the proof below rises into the middle while one roller pass moves the ink off the old word and onto the new one. Click a proof or press ↑ ↓ to turn it yourself, so it works as a headline that cycles who it's for or as a picker for plans.

Rotating Text Headline for React: a giant word turns like a reel between halftone proofs. Built with Tailwind CSS; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/echo-reel
- Tags: Text, Hero, Motion
- npm dependencies: clsx, tailwind-merge
- Inspired by: [Cloudflare for Startups](https://www.cloudflare.com/startups/)

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/echo-reel.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/echo-reel
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/echo-reel.json
```

## Usage

The main demo shows typical usage.

### Audiences

A video tool's hero that cycles who it's made for. It holds while you point at it and stops once you pick a word.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/echo-reel-demo
```

```tsx title="components/echo-reel-demo.tsx"
import { EchoReel } from "@/components/ui/echo-reel";

const AUDIENCES = ["Creators", "Podcasters", "Studios", "Brands"];

export function EchoReelDemo() {
  return (
    <section className="w-full max-w-4xl overflow-hidden rounded-3xl border border-[#0b0b0d]/10 bg-white px-5 pt-10 pb-12 text-[#0b0b0d] sm:px-10 sm:pt-12 dark:border-white/10 dark:bg-[#05060d] dark:text-[#f4f4f6]">
      <h2 className="sr-only">Cutline is made for creators, podcasters, studios and brands</h2>
      <p aria-hidden className="text-center text-sm font-semibold tracking-tight sm:text-base">
        Cutline is made for
      </p>
      <EchoReel words={AUDIENCES} className="mt-2 text-[#2f3cff] dark:text-[#7b86ff]" />
      <p className="mx-auto mt-5 max-w-[22rem] text-center text-[15px] leading-relaxed text-[#0b0b0d]/65 dark:text-[#f4f4f6]/65">
        One long recording in. Clips, captions and dubs for every channel out, cut while you sleep.
      </p>
      <div className="mt-6 flex justify-center">
        <a
          href="#start"
          className="rounded-full bg-[#2f3cff] px-5 py-2.5 text-sm font-semibold text-white transition-colors duration-150 hover:bg-[#1f2be6] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2f3cff] dark:bg-[#5b68ff] dark:hover:bg-[#6d79ff] dark:focus-visible:outline-[#7b86ff]"
        >
          Start cutting free
        </a>
      </div>
    </section>
  );
}
```

## Examples

Finished sections built with Echo Reel. Each one installs whole, with the component included.

### Plan Picker

The giant word picks a pricing plan: click the proof above or below, or use the arrow keys, and the price card beside it changes.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/echo-reel-plans-demo
```

```tsx title="components/echo-reel-plans-demo.tsx"
"use client";

import * as React from "react";

import { EchoReel } from "@/components/ui/echo-reel";

const PLANS = [
  {
    name: "Solo",
    price: 12,
    blurb: "For one brand you're building yourself.",
    lines: [
      "One brand kit",
      "Logo, palette and type pairings",
      "Export to Figma and PDF",
    ],
    cta: "Start with Solo",
  },
  {
    name: "Team",
    price: 36,
    blurb: "For an in-house team keeping five brands in line.",
    lines: [
      "Five brand kits, five seats",
      "Shared templates and review links",
      "Version history on every asset",
    ],
    cta: "Try Team free for 14 days",
  },
  {
    name: "Studio",
    price: 96,
    blurb: "For agencies handing kits to clients.",
    lines: [
      "Unlimited client kits, 20 seats",
      "White-label handoff pages",
      "Priority support and SSO",
    ],
    cta: "Talk to us about Studio",
  },
];

function Check() {
  return (
    <svg
      viewBox="0 0 16 16"
      aria-hidden
      className="mt-0.5 size-4 shrink-0 text-[#ff3d1f] dark:text-[#ff6a4d]"
    >
      <path
        d="M3.5 8.5l3 3 6-7"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.75"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export function EchoReelPlansDemo() {
  const [plan, setPlan] = React.useState(1);

  return (
    <section className="@container w-full max-w-4xl overflow-hidden rounded-3xl border border-[#0b0b0d]/10 bg-white px-5 pt-8 pb-8 text-[#0b0b0d] @[44rem]:px-10 @[44rem]:pb-10 dark:border-white/10 dark:bg-[#08080b] dark:text-[#f4f4f6]">
      <div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
        <h2 className="text-lg font-semibold tracking-tight">Tinta pricing</h2>
        <p className="text-[13px] text-[#0b0b0d]/55 dark:text-[#f4f4f6]/55">
          Click a plan above or below, or use <kbd className="font-sans">↑</kbd>{" "}
          <kbd className="font-sans">↓</kbd>
        </p>
      </div>

      <div className="mt-6 grid items-center gap-6 @[44rem]:mt-8 @[44rem]:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)] @[44rem]:gap-10">
        <EchoReel
          words={PLANS.map((p) => p.name)}
          value={plan}
          onValueChange={(index) => setPlan(index)}
          interval={false}
          aria-label="Plan"
          className="mx-auto max-w-md text-[#ff3d1f] dark:text-[#ff5a3a]"
        />

        {/* Every plan in one grid cell, so the card keeps its height while they cross. */}
        <div className="mx-auto grid w-full max-w-sm">
          {PLANS.map((p, i) => {
            const on = i === plan;
            return (
              <div
                key={p.name}
                aria-hidden={!on}
                inert={!on}
                className="[grid-area:1/1] rounded-2xl border border-[#0b0b0d]/10 bg-white p-5 shadow-sm transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-opacity dark:border-white/10 dark:bg-white/[0.04]"
                style={{
                  opacity: on ? 1 : 0,
                  transform: on ? "none" : `translateY(${i < plan ? -6 : 6}px)`,
                  transitionDelay: on ? "120ms" : "0ms",
                }}
              >
                <div className="flex items-baseline gap-1.5">
                  <span className="text-4xl font-semibold tracking-tight tabular-nums">
                    ${p.price}
                  </span>
                  <span className="text-sm text-[#0b0b0d]/55 dark:text-[#f4f4f6]/55">
                    a month
                  </span>
                </div>
                <p className="mt-1 text-sm text-[#0b0b0d]/65 dark:text-[#f4f4f6]/65">
                  {p.blurb}
                </p>
                <ul className="mt-4 space-y-2 text-sm">
                  {p.lines.map((line) => (
                    <li key={line} className="flex gap-2">
                      <Check />
                      {line}
                    </li>
                  ))}
                </ul>
                <a
                  href="#plan"
                  tabIndex={on ? undefined : -1}
                  className="mt-5 flex h-10 items-center justify-center rounded-full bg-[#ff3d1f] text-sm font-semibold text-white transition-colors duration-150 hover:bg-[#e8331a] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ff3d1f] dark:bg-[#ff5a3a] dark:text-[#0b0b0d] dark:hover:bg-[#ff6e52]"
                >
                  {p.cta}
                </a>
              </div>
            );
          })}
        </div>
      </div>
    </section>
  );
}
```

### Footer Wordmark

A studio's footer whose wordmark turns to whichever link you point at or tab to, and back to the name when you leave.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/echo-reel-footer-demo
```

```tsx title="components/echo-reel-footer-demo.tsx"
"use client";

import * as React from "react";

import { EchoReel } from "@/components/ui/echo-reel";

const BRAND = "Fieldwork";
const LINKS = [
  { label: "Work", href: "#work" },
  { label: "Studio", href: "#studio" },
  { label: "Journal", href: "#journal" },
  { label: "Contact", href: "#contact" },
];
const WORDS = [BRAND, ...LINKS.map((link) => link.label)];

export function EchoReelFooterDemo() {
  const [word, setWord] = React.useState(0);
  const back = React.useRef<number | undefined>(undefined);

  const show = (index: number) => {
    window.clearTimeout(back.current);
    setWord(index);
  };
  // Wait a beat before going back to the name, so moving between links
  // doesn't turn the reel twice.
  const release = () => {
    window.clearTimeout(back.current);
    back.current = window.setTimeout(() => setWord(0), 250);
  };

  React.useEffect(() => () => window.clearTimeout(back.current), []);

  return (
    <footer className="w-full max-w-4xl overflow-hidden rounded-3xl border border-[#0b0b0d]/10 bg-white px-5 pt-8 pb-5 text-[#0b0b0d] sm:px-10 dark:border-white/10 dark:bg-[#07080c] dark:text-[#f4f4f6]">
      <div className="flex flex-wrap items-start justify-between gap-x-10 gap-y-6">
        <div className="max-w-[17rem]">
          <p className="flex items-center gap-2 text-sm font-semibold tracking-tight">
            <span aria-hidden className="size-2.5 rounded-full bg-[#c6ff4f] ring-1 ring-[#0b0b0d]/15 dark:ring-0" />
            Fieldwork Studio
          </p>
          <p className="mt-2 text-sm leading-relaxed text-[#0b0b0d]/60 dark:text-[#f4f4f6]/60">
            Brand, product and type for companies that ship. Lisbon and remote.
          </p>
          <a
            href="#contact"
            className="mt-3 inline-block rounded text-sm font-medium underline decoration-[#0b0b0d]/25 underline-offset-4 hover:decoration-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current dark:decoration-[#f4f4f6]/30"
          >
            hello@fieldwork.studio
          </a>
        </div>

        <nav aria-label="Footer" onPointerLeave={release} onBlur={release}>
          <ul className="grid grid-cols-2 gap-x-10 gap-y-1 text-sm font-medium">
            {LINKS.map((link, i) => (
              <li key={link.href}>
                <a
                  href={link.href}
                  onPointerEnter={() => show(i + 1)}
                  onFocus={() => show(i + 1)}
                  className="-mx-1.5 inline-flex rounded-md px-1.5 py-1 text-[#0b0b0d]/70 transition-colors duration-150 hover:text-[#0b0b0d] focus-visible:outline-2 focus-visible:outline-current dark:text-[#f4f4f6]/70 dark:hover:text-[#c6ff4f]"
                >
                  {link.label}
                </a>
              </li>
            ))}
          </ul>
        </nav>
      </div>

      <EchoReel
        words={WORDS}
        value={word}
        onValueChange={(index) => setWord(index)}
        interval={false}
        className="mt-8 text-[#0b0b0d] dark:text-[#c6ff4f]"
      />

      <div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-[#0b0b0d]/10 pt-4 text-xs text-[#0b0b0d]/55 dark:border-white/10 dark:text-[#f4f4f6]/50">
        <span>© 2026 Fieldwork Studio Lda.</span>
        <a href="#top" className="rounded hover:text-current focus-visible:outline-2 focus-visible:outline-current">
          Back to top ↑
        </a>
      </div>
    </footer>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `words` (required) | `string[]` |  | The words on the reel, in order. The next word waits below the current one and the previous one sits above. A single word prints a still stack of itself. |
| `value` | `number` |  | The index of the word in the middle, when the parent owns it. Pair it with `onValueChange`. |
| `defaultValue` | `number` | `0` | The index the reel starts on when it runs by itself. |
| `onValueChange` | `(index: number, reason: "auto" \| "user") => void` |  | Called with the new index each time the reel turns, and whether autoplay or the visitor turned it. |
| `interval` | `number \| false` | `3` | Seconds between turns by itself, or `false` to wait for the visitor. Hover and focus hold it, and the visitor's first turn stops it. |
| `speed` | `number` | `1` | How fast each turn plays, as a multiple of the default pace: the reel moving a row, the roller handing the ink over, the first print and the fades, together. `2` is twice as fast. |
| `echoes` | `1 \| 2` | `1` | How many proofs sit on each side of the word. |
| `className` | `string` |  | Extra classes for the reel. Its text color is the ink; set `--echo-reel-tint` for the proofs (default the ink at 38%) and `--echo-reel-dot` for the halftone pitch (default `clamp(4px, 0.028em, 8px)`). The longest word is sized to fill the width, so size the reel with its container. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/echo-reel.tsx`, keep these, which make it feel and behave right:

- Rows move by `transform` and `opacity` only. The ink wipes by sliding its clipping window one way and the word inside it the other by the same amount, on one `rollTransition`, so the word never shifts.
- Rows are keyed by absolute slot and the reel only turns the short way round, so each row keeps its element through a wrap; `reach` mounts enough rows that a jump never mounts one in view.
- On a turn the roller trails the reel slightly on an in-out curve like the reel's, so the ink is handed over while the rows travel. `print` times only the first inking on view, before any turn.
- Proof buttons are keyed by side, not by word, so focus stays put as the reel turns. A visitor's turn stops autoplay for good and is announced politely; autoplay turns are not.
- Reduced motion swaps the rows at once and fades the new ink in over its proof, which drops only once that fade ends. The reduced flag reads false while hydrating, so the markup matches.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/echo-reel.tsx"
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - Rows move by `transform` and `opacity` only. The ink wipes by sliding its clipping window one
 *   way and the word inside it the other by the same amount, on one `rollTransition`, so the word
 *   never shifts.
 * - Rows are keyed by absolute slot and the reel only turns the short way round, so each row keeps
 *   its element through a wrap; `reach` mounts enough rows that a jump never mounts one in view.
 * - On a turn the roller trails the reel slightly on an in-out curve like the reel's, so the ink is
 *   handed over while the rows travel. `print` times only the first inking on view, before any turn.
 * - Proof buttons are keyed by side, not by word, so focus stays put as the reel turns. A visitor's
 *   turn stops autoplay for good and is announced politely; autoplay turns are not.
 * - Reduced motion swaps the rows at once and fades the new ink in over its proof, which drops only
 *   once that fade ends. The reduced flag reads false while hydrating, so the markup matches.
 */
const TIMING = {
  /** The reel moving one row, in ms, on its strong in-out curve. */
  turn: { duration: 700, ease: "cubic-bezier(0.77, 0, 0.175, 1)" },
  /**
   * The roller moving the ink across on a turn, in ms: its run, its lag behind the reel, its curve.
   * On a turn the roller rides the reel's own curve, so the ink crosses while the rows travel
   * instead of landing before they've moved.
   */
  roll: { duration: 640, delay: 60, ease: "cubic-bezier(0.65, 0, 0.35, 1)" },
  /** The first ink when the reel comes into view, in ms. */
  print: { duration: 700, delay: 150, ease: "cubic-bezier(0.23, 1, 0.32, 1)" },
  /** A word's proof going once the ink covers it, in ms: its fade, the wait first, its curve. */
  proof: { duration: 300, hold: 450, ease: "ease-out" },
  /** A row leaving or joining the visible stack, in ms, and its curve. */
  fade: { duration: 400, ease: "ease-out" },
  /** The ink fading in place under reduced motion, in ms, and its curve. */
  reduced: { duration: 250, ease: "ease-out" },
  /** Between turns by itself, in seconds (the `interval` prop). */
  interval: 3,
  /** How fast each turn plays, as a multiple of the default pace (the `speed` prop). */
  speed: 1,
  /** The slowest pace `speed` is held to, as a multiple of the default pace. */
  minSpeed: 0.05,
} as const;

/* ─────────────────────────────────────────────────────────
 * ECHO REEL STORYBOARD
 *
 *  rest     one giant word printed solid in the ink. The words
 *           before and after it in the list sit above and below
 *           as pale halftone proofs, each row overlapping the
 *           next, like the impressions round a print reel
 *  view     the first time it shows, the middle word is still a
 *           proof. 150ms later a roller inks it, left to right,
 *           in 0.7s
 *  turn     every `interval` seconds the reel turns one row in
 *           0.7s (strong in-out). One roller pass, 60ms behind and
 *           on the same in-out curve, moves the ink across: it
 *           comes off the old word left to right as that word
 *           rises into the top proof, and goes onto the word rising
 *           into the middle at the same edge, so the ink is handed
 *           over in transit. The row leaving the top fades as it
 *           goes; the next word fades in below
 *  back     a turn the other way runs in reverse: the reel drops
 *           and the roller travels right to left
 *  pick     click a proof, or focus it and press ↑ ↓, and the reel
 *           turns to that word. A visitor's turn stops the
 *           autoplay for good and is announced politely; autoplay
 *           turns are not announced
 *  hover    a proof under the pointer darkens a step, so it reads
 *           as something to press. Hover or focus on the reel
 *           holds the autoplay
 *  sleep    autoplay waits while the reel is off screen or the tab
 *           is hidden
 *  reduced  nothing travels and nothing wipes: the rows swap at
 *           once and the new word's ink fades in over its proof
 *           in 0.25s
 * ───────────────────────────────────────────────────────── */

const REEL = {
  step: 0.68, // em between one row and the next; rows are 1.32em tall, so they overlap
  pad: { x: 0.08, y: 0.16 }, // em around each word, so a wipe never clips a glyph
  fit: 0.94, // share of the width the longest word fills
  far: 0.5, // opacity of the outer proofs with echoes={2}
  measure: 100, // px the words are measured at
} as const;

const ROW = 1 + REEL.pad.y * 2; // em, one row's height
const STEP_PCT = (REEL.step / ROW) * 100; // a row's step as a share of its own height
const CAP_TOP = 0.29; // em from a row's top to its capitals
const BASELINE = 1.01; // em from a row's top to its baseline

const mod = (a: number, n: number) => ((a % n) + n) % n;

/** The signed shortest way round a reel of `n` from index `a` to `b`; a tie goes forward. */
function shortest(a: number, b: number, n: number) {
  const d = mod(b - a, n);
  return d > n / 2 ? d - n : d;
}

const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";

function subscribeReduced(onChange: () => void) {
  const query = window.matchMedia(REDUCED_QUERY);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}

/** Live, and false on the server and in the hydration render, so the markup matches. */
function usePrefersReducedMotion() {
  return React.useSyncExternalStore(
    subscribeReduced,
    () => window.matchMedia(REDUCED_QUERY).matches,
    () => false,
  );
}

export interface EchoReelProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
  /** The words on the reel, in order. The next word waits below the current one and the previous one sits above. A single word prints a still stack of itself. */
  words: string[];
  /** The index of the word in the middle, when the parent owns it. Pair it with `onValueChange`. */
  value?: number;
  /** The index the reel starts on when it runs by itself. */
  defaultValue?: number;
  /** Called with the new index each time the reel turns, and whether autoplay or the visitor turned it. */
  onValueChange?: (index: number, reason: "auto" | "user") => void;
  /** Seconds between turns by itself, or `false` to wait for the visitor. Hover and focus hold it, and the visitor's first turn stops it. */
  interval?: number | false;
  /** How many proofs sit on each side of the word. */
  echoes?: 1 | 2;
  /** How fast each turn plays, as a multiple of the default pace: the reel, the roller, the first print and the fades together. `2` is twice as fast. */
  speed?: number;
}

/* ── Row ──────────────────────────────────────────────── */
// One word on the reel: its halftone proof, and the ink over it behind a
// window that slides across to roll the ink on or off.

type RowProps = {
  word: string;
  offset: number;
  echoes: number;
  inked: boolean;
  first: boolean;
  hot: boolean;
  reduced: boolean;
  speed: number;
};

const Row = React.memo(function Row({ word, offset, echoes, inked, first, hot, reduced, speed }: RowProps) {
  const away = Math.abs(offset);
  const solid = offset === 0 && inked;
  // Where the ink's window sits: over the word, waiting to its left (rows still
  // to come, below), or gone off to its right (rows already printed, above).
  const gate = solid ? 0 : offset < 0 ? 100 : -100;
  const roll = solid && first ? TIMING.print : TIMING.roll;
  // Every move on a turn, scaled together by `speed`.
  const ms = (t: number) => t / speed;
  const rollTransition = reduced ? "none" : `transform ${ms(roll.duration)}ms ${roll.ease} ${ms(roll.delay)}ms`;

  const pad = `${REEL.pad.y}em ${REEL.pad.x}em`;

  return (
    <div
      className="absolute inset-x-0 flex justify-center"
      style={{
        top: `${echoes * REEL.step}em`,
        height: `${ROW}em`,
        zIndex: 10 - away,
        transform: `translateY(${offset * STEP_PCT}%)`,
        opacity: away > echoes ? 0 : away === 2 ? REEL.far : 1,
        visibility: away > echoes + 1 ? "hidden" : undefined,
        willChange: away <= 1 ? "transform" : undefined,
        transition: reduced
          ? "none"
          : `transform ${ms(TIMING.turn.duration)}ms ${TIMING.turn.ease}, opacity ${ms(TIMING.fade.duration)}ms ${TIMING.fade.ease}`,
        ...(hot
          ? ({ "--echo-reel-tint": "color-mix(in oklab, var(--echo-reel-ink) 62%, transparent)" } as React.CSSProperties)
          : null),
      }}
    >
      <span className="relative block whitespace-nowrap" style={{ padding: pad }}>
        <span className="invisible">{word}</span>

        {/* The proof: a pale fill and a hairline, with a halftone over it. */}
        <span
          className="absolute inset-0"
          style={{
            opacity: solid ? 0 : 1,
            transition: reduced
              ? solid
                ? `opacity 0s linear ${TIMING.reduced.duration}ms`
                : "none"
              : `opacity ${ms(TIMING.proof.duration)}ms ${TIMING.proof.ease} ${solid ? ms(TIMING.proof.hold) : 0}ms`,
          }}
        >
          <span
            className="absolute inset-0"
            style={{
              padding: pad,
              WebkitTextFillColor: "transparent", // not `color`, which would turn the tint (from currentColor) transparent too
              WebkitTextStroke: "max(1px, 0.005em) var(--echo-reel-tint)",
              backgroundImage:
                "linear-gradient(color-mix(in oklab, var(--echo-reel-tint) 26%, transparent) 0 0)",
              WebkitBackgroundClip: "text",
              backgroundClip: "text",
            }}
          >
            {word}
          </span>
          <span
            className="absolute inset-0"
            style={{
              padding: pad,
              WebkitTextFillColor: "transparent", // not `color`, which would turn the tint (from currentColor) transparent too
              // Two offset grids of dots make the 45° screen of a halftone.
              backgroundImage:
                "radial-gradient(circle closest-side, var(--echo-reel-tint) 0 44%, transparent 58%), radial-gradient(circle closest-side, var(--echo-reel-tint) 0 44%, transparent 58%)",
              backgroundSize: "var(--echo-reel-dot) var(--echo-reel-dot)",
              backgroundPosition: "0 0, calc(var(--echo-reel-dot) / 2) calc(var(--echo-reel-dot) / 2)",
              WebkitBackgroundClip: "text",
              backgroundClip: "text",
              maskImage: "radial-gradient(ellipse 62% 78% at 50% 58%, #000 25%, transparent 100%)",
              WebkitMaskImage: "radial-gradient(ellipse 62% 78% at 50% 58%, #000 25%, transparent 100%)",
            }}
          >
            {word}
          </span>
        </span>

        {/* The ink, behind a window that slides across while the word stays put. */}
        <span
          className="absolute inset-0 overflow-hidden"
          style={{
            transform: reduced ? undefined : `translateX(${gate}%)`,
            opacity: reduced ? (solid ? 1 : 0) : undefined,
            willChange: away <= 1 && !reduced ? "transform" : undefined,
            transition: reduced
              ? solid
                ? `opacity ${TIMING.reduced.duration}ms ${TIMING.reduced.ease}`
                : "none"
              : rollTransition,
          }}
        >
          <span
            className="block size-full"
            style={{
              padding: pad,
              color: "var(--echo-reel-ink)",
              transform: reduced ? undefined : `translateX(${-gate}%)`,
              willChange: away <= 1 && !reduced ? "transform" : undefined,
              transition: rollTransition,
            }}
          >
            {word}
          </span>
        </span>
      </span>
    </div>
  );
});

/* ── EchoReel ─────────────────────────────────────────── */

export function EchoReel({
  words,
  value,
  defaultValue = 0,
  onValueChange,
  interval = TIMING.interval,
  echoes = 1,
  speed = TIMING.speed,
  className,
  style,
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: EchoReelProps) {
  const n = words.length;
  const spins = n > 1;
  const reduced = usePrefersReducedMotion();

  const rootRef = React.useRef<HTMLDivElement>(null);
  const reelRef = React.useRef<HTMLDivElement>(null);
  const measureRef = React.useRef<HTMLSpanElement>(null);

  // The reel's absolute position: slot k shows words[k mod n]. It only ever
  // moves the short way round, so each row keeps its element through a wrap.
  const [slot, setSlot] = React.useState(() => value ?? defaultValue);
  const [seen, setSeen] = React.useState(value);
  if (spins && value !== undefined && value !== seen) {
    setSeen(value);
    setSlot(slot + shortest(mod(slot, n), value, n));
  }
  const index = n ? mod(slot, n) : 0;

  const [printed, setPrinted] = React.useState(false);
  const [turned, setTurned] = React.useState(false);
  const [inView, setInView] = React.useState(false);
  const [visible, setVisible] = React.useState(true);
  const [hovered, setHovered] = React.useState(false);
  const [focused, setFocused] = React.useState(false);
  const [stopped, setStopped] = React.useState(false);
  const [hot, setHot] = React.useState<number | null>(null);
  const [announce, setAnnounce] = React.useState("");
  const inked = printed || reduced;

  const turn = (delta: number, reason: "auto" | "user") => {
    if (!spins || delta === 0) return;
    const next = mod(slot + delta, n);
    setSlot(slot + delta);
    setTurned(true);
    if (value !== undefined) setSeen(next);
    if (reason === "user") {
      setStopped(true);
      setAnnounce(words[next]);
    }
    onValueChange?.(next, reason);
  };

  // The timer calls the latest `turn` without restarting on every render.
  const turnRef = React.useRef(turn);
  React.useLayoutEffect(() => {
    turnRef.current = turn;
  });

  const every = interval === false ? 0 : interval;
  const playing = spins && every > 0 && inked && inView && visible && !stopped && !hovered && !focused;

  React.useEffect(() => {
    if (!playing) return;
    const id = window.setTimeout(() => turnRef.current(1, "auto"), every * 1000);
    return () => window.clearTimeout(id);
  }, [playing, every, slot]);

  // Print on first view; autoplay sleeps off screen.
  React.useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const io = new IntersectionObserver(
      ([entry]) => {
        setInView(entry.isIntersecting);
        if (entry.isIntersecting) setPrinted(true);
      },
      { threshold: 0.25 },
    );
    io.observe(root);
    return () => io.disconnect();
  }, []);

  React.useEffect(() => {
    const sync = () => setVisible(!document.hidden);
    document.addEventListener("visibilitychange", sync);
    return () => document.removeEventListener("visibilitychange", sync);
  }, []);

  // Fit the longest word to the width once the font is in, and on every resize.
  const key = words.join("\n");
  React.useLayoutEffect(() => {
    const root = rootRef.current;
    const reel = reelRef.current;
    const measure = measureRef.current;
    if (!root || !reel || !measure) return;
    let alive = true;
    const fit = () => {
      if (!alive) return;
      let widest = 0;
      for (const child of Array.from(measure.children)) {
        widest = Math.max(widest, child.getBoundingClientRect().width);
      }
      const width = root.clientWidth;
      if (!widest || !width) return;
      reel.style.fontSize = `${((width * REEL.fit) / widest) * REEL.measure}px`;
    };
    fit();
    const ro = new ResizeObserver(fit);
    ro.observe(root);
    document.fonts?.ready.then(fit);
    return () => {
      alive = false;
      ro.disconnect();
    };
  }, [key]);

  if (!n) return null;

  const longest = Math.max(...words.map((word) => word.length), 1);
  // Before measuring (and on the server): roughly 0.56em a letter at this weight.
  const fallback = `${((REEL.fit * 100) / (longest * 0.56)).toFixed(2)}cqi`;

  // Enough rows either side that a jump the short way round never mounts a
  // row where it can be seen.
  const reach = spins ? echoes + 1 + Math.floor(n / 2) : echoes;
  const rows: number[] = [];
  for (let k = slot - reach; k <= slot + reach; k++) rows.push(k);

  const sides: number[] = [];
  if (spins) for (let o = -echoes; o <= echoes; o++) if (o !== 0) sides.push(o);

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowDown" || e.key === "ArrowRight") {
      e.preventDefault();
      turn(1, "user");
    } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
      e.preventDefault();
      turn(-1, "user");
    }
  };

  return (
    <div
      ref={rootRef}
      className={cn(
        "@container/echo-reel relative w-full select-none [--echo-reel-ink:currentColor] [--echo-reel-tint:color-mix(in_oklab,var(--echo-reel-ink)_38%,transparent)] [--echo-reel-dot:clamp(4px,0.028em,8px)]",
        className,
      )}
      style={style}
      onPointerEnter={(e) => {
        if (e.pointerType === "mouse") setHovered(true);
        onPointerEnter?.(e);
      }}
      onPointerLeave={(e) => {
        setHovered(false);
        setHot(null);
        onPointerLeave?.(e);
      }}
      onFocus={(e) => {
        setFocused(true);
        onFocus?.(e);
      }}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
        onBlur?.(e);
      }}
      {...props}
    >
      <span className="sr-only">{words[index]}</span>
      <span className="sr-only" aria-live="polite">
        {announce}
      </span>

      <div
        ref={reelRef}
        className="relative font-black leading-none tracking-[-0.06em] [overflow:clip]"
        style={{ fontSize: fallback, height: `${ROW + echoes * 2 * REEL.step}em` }}
      >
        <div aria-hidden className="absolute inset-0">
          {rows.map((k) => (
            <Row
              key={k}
              word={words[mod(k, n)]}
              offset={k - slot}
              echoes={echoes}
              inked={inked}
              first={!turned}
              hot={hot === k - slot}
              reduced={reduced}
              speed={Math.max(TIMING.minSpeed, speed)}
            />
          ))}
        </div>

        {/* A button over each proof's visible band. Kept by side, not by word,
            so focus stays put while the reel turns under it. */}
        {sides.map((o) => (
          <button
            key={o}
            type="button"
            aria-label={`Show ${words[mod(slot + o, n)]}`}
            className="absolute inset-x-0 z-20 cursor-pointer rounded-[0.12em] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--echo-reel-ink)]"
            style={{
              top: `${(echoes + (o < 0 ? o : o - 1)) * REEL.step + (o < 0 ? CAP_TOP : BASELINE)}em`,
              height: `${REEL.step}em`,
            }}
            onPointerEnter={(e) => {
              if (e.pointerType === "mouse") setHot(o);
            }}
            onPointerLeave={() => setHot(null)}
            onFocus={() => setHot(o)}
            onBlur={() => setHot(null)}
            onClick={() => turn(o, "user")}
            onKeyDown={onKeyDown}
          />
        ))}
      </div>

      {/* Every word at a fixed size, for fitting the longest to the width. */}
      <span
        ref={measureRef}
        aria-hidden
        className="pointer-events-none invisible absolute top-0 left-0 font-black leading-none tracking-[-0.06em] whitespace-nowrap"
        style={{ fontSize: REEL.measure }}
      >
        {words.map((word, i) => (
          <span key={i} className="absolute top-0 left-0">
            {word}
          </span>
        ))}
      </span>
    </div>
  );
}
```
