# Caption Ticker

> An animated caption ticker for React and shadcn, stepping short captions in under a scene one line at a time.

Short captions that step in under a scene with Text Swap's motion: the old line lifts and blurs out, the new one rises in, and the width springs to fit. Each line has its own timing, or a scene's timeline drives it; screen readers get every line at once.

- Tier: Free (MIT)
- Interaction: Slide
- Page: https://eased.dev/components/caption-ticker
- Registry: https://eased.dev/r/caption-ticker.json

## Install

```bash
npx shadcn add https://eased.dev/r/caption-ticker.json
```

Short captions that step in under a scene, one line at a time, with [Text Swap](/components/text-swap)'s motion. It runs on its own clock or follows a scene's timeline (eased-demo-core).

## When to use

- Narrating a product demo: one short line per step, under the stage.
- A hero or empty state that cycles through what a product does, each line on its own timing.
- Subtitles for a looping illustration.

## When not to use

- Status that the person needs to act on: use an [Alert](/components/alert) or a [Toast](/components/toast).
- Long text or more than a few words: a caption never wraps. Write a paragraph.
- A label that changes because of something the person did: use [Text Swap](/components/text-swap) directly.

## Usage

```tsx example.tsx
import { CaptionTicker } from "@/components/eased/caption-ticker/caption-ticker";

export function Example() {
  return (
    <CaptionTicker
      captions={[
        { text: "Connect your repository", duration: 2000 },
        { text: "Push to main", duration: 1600 },
        { text: "Share the preview link", duration: 2000 },
      ]}
    />
  );
}
```

## Examples

### Driven by a scene

Pass `index` and the ticker shows that line; the scene's `useDemoTimeline` decides when.

```tsx
<CaptionTicker captions={["Press Deploy", "Fathom builds the commit", "Live at fathom.app"]} index={step === "build" ? 1 : step === "live" ? 2 : 0} />
```

### Following along

`onIndexChange` reports each line on the ticker's own clock, so what it describes can light with it; `paused` holds it from a Pause control.

```tsx
<CaptionTicker captions={CAPTIONS} paused={paused} onIndexChange={setCurrent} />
```

## API reference

### CaptionTicker

| Prop | Type | Default | Description |
|---|---|---|---|
| `captions` | `(string \| { text: string; duration?: number })[]` | | The lines. `duration` is how long a line stays up on its own clock, in ms. |
| `index` | `number \| null` | | Show this line (driven by a scene). `null` shows none. Leave it out to run on its own clock. |
| `loop` | `boolean` | `true` | On its own clock, start again after the last line. |
| `paused` | `boolean` | `false` | Hold the current line on its own clock. |
| `duration` | `number` | `2400` | How long a line without a `duration` stays up, in ms. |
| `onIndexChange` | `(index: number) => void` | | Called as its own clock moves to a line. |
| `className` | `string` | `""` | Appended to the root's classes. |

## Accessibility

- The moving line is `aria-hidden`; screen readers get every caption at once as a list, so a loop never repeats at them.
- Nothing is focusable. If the captions narrate an autoplaying scene, the scene's Pause control holds both.

## Motion

Each new line rises 4px from below with a 4px blur on the `snappy` spring while the old one lifts 3px and blurs out on a faster tween, and the box springs its width to the new line. On its own clock the ticker schedules each line from when the last was due, so it never drifts; it pauses off-screen and in a hidden tab. Under reduced motion lines cross-fade in 100ms, and its own clock rests on the last line.

## Notes for AI

Use Caption Ticker under a demo stage to narrate it, with `index` derived from the same `useDemoTimeline` step as everything else, or on its own with a `duration` per line. Keep every line to a few words.

Do not drive it with your own `setInterval`, and do not put it in a live region. It already pauses off-screen, swaps without layout jumps and exposes all lines to screen readers.

## Related

- [Text Swap](/components/text-swap): the motion it uses, for any changing label.
- [Demo Cursor](/components/demo-cursor): what it usually narrates.
- [Product demo set](/sets/product-demo): the pieces on one timeline.

## Example

`components/eased/caption-ticker/caption-ticker-demo.tsx`

```tsx
"use client";

import { useState, type ReactNode } from "react";
import { Button } from "../button/button";
import { DemoFrame } from "../lib/demo-frame";
import { CaptionTicker, type Caption } from "./caption-ticker";

/** Each line stays up as long as it takes to read: the short ones go quicker. */
const CAPTIONS: Caption[] = [
  { text: "Connect your repository", duration: 2000 },
  { text: "Push to main", duration: 1600 },
  { text: "Fathom builds every commit", duration: 2400 },
  { text: "Share the preview link", duration: 2000 },
];

const STROKE = { fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" } as const;

const STEPS: { label: string; icon: ReactNode }[] = [
  { label: "Repository", icon: <path d="M3.5 2.75h7.75v10.5H4.75c-.7 0-1.25-.55-1.25-1.25V2.75Zm0 8.5c0-.7.55-1.25 1.25-1.25h6.5" /> },
  { label: "Push", icon: <path d="M8 13.25V3M4.25 6.75 8 3l3.75 3.75" /> },
  { label: "Build", icon: <path d="M2.75 5.25 8 2.25l5.25 3v5.5L8 13.75l-5.25-3v-5.5ZM2.75 5.25 8 8.25l5.25-3M8 8.25v5.5" /> },
  {
    label: "Preview",
    icon: <path d="M6.75 9.25a2.75 2.75 0 0 0 3.9 0l2.1-2.1a2.75 2.75 0 0 0-3.9-3.9l-.6.6M9.25 6.75a2.75 2.75 0 0 0-3.9 0l-2.1 2.1a2.75 2.75 0 0 0 3.9 3.9l.6-.6" />,
  },
];

/**
 * Onboarding for Fathom, narrated: four captions on the ticker's own clock, each with its own
 * timing, and the step each one describes lights as it comes up. Pause holds the line;
 * Replay starts from the first.
 */
export function CaptionTickerDemo() {
  const [current, setCurrent] = useState(0);
  const [paused, setPaused] = useState(false);
  const [run, setRun] = useState(0);

  return (
    <DemoFrame
      title="Get started"
      actions={
        <div className="-mr-2 flex items-center gap-1">
          <Button variant="ghost" size="sm" onClick={() => setPaused((p) => !p)}>
            {paused ? "Play" : "Pause"}
          </Button>
          <Button
            variant="ghost"
            size="sm"
            onClick={() => {
              setCurrent(0);
              setPaused(false);
              setRun((r) => r + 1);
            }}
          >
            Replay
          </Button>
        </div>
      }
    >
      <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-8 p-(--pad)">
        <ol aria-label="Steps" className="grid w-full grid-cols-4 gap-2">
          {STEPS.map((s, i) => (
            <li key={s.label} className="flex min-w-0 flex-col items-center gap-2" aria-current={i === current ? "step" : undefined}>
              <span
                className={`grid size-11 place-items-center rounded-panel border transition-colors duration-150 ease-standard ${
                  i === current ? "border-brand-line bg-brand-subtle text-brand-text" : "border-line-subtle bg-surface-sunken text-text-muted"
                }`}
              >
                <svg viewBox="0 0 16 16" aria-hidden className="size-4.5" {...STROKE}>
                  {s.icon}
                </svg>
              </span>
              <span className={`truncate text-ui-xs transition-colors duration-150 ease-standard ${i === current ? "text-text" : "text-text-muted"}`}>{s.label}</span>
            </li>
          ))}
        </ol>
        <CaptionTicker key={run} captions={CAPTIONS} paused={paused} onIndexChange={setCurrent} />
      </div>
    </DemoFrame>
  );
}
```
