# Telegraph — Animated UI Component for React

> A self-contained terminal that types a scripted session on its own — macOS window chrome, command lines typing behind a cool prompt sigil with a blinking block caret, output blocks fading in whole, auto-sequenced on scroll-into-view. Real selectable text, single active-line rAF that pauses offscreen, fully static under reduced motion.

- Page: https://crucibleui.com/components/utility/telegraph
- Category: [utility](https://crucibleui.com/components/utility)
- Tier: free (full source)
- Engine: css

## Install

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

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

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

## Props (`TelegraphProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `lines?` | `TelegraphLine[]` | `a short install/deploy session` | The scripted session, top to bottom. |
| `title?` | `string` | `"zsh — ~/my-app"` | Window title shown in the title bar. |
| `prompt?` | `string` | `"$"` | Default prompt sigil for command lines. |
| `accent?` | `string` | `sky "#7dd3fc"` | The single cool accent — used for the prompt sigil, the caret glow, and the `accent`/`string` tones. Any CSS color. |
| `speed?` | `number` | `1` | Typing-rate multiplier (and reveal cadence). |
| `startDelay?` | `number` | `0.3` | Seconds to wait after scrolling into view before typing begins. |
| `loop?` | `boolean` | `false` | Replay the session on a loop instead of resting at the end. |
| `paused?` | `boolean` | `false` | Freeze at the fully rendered final transcript, no typing. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"figure">, "content" | "children">`.

## Usage example

```tsx
"use client";

import { Telegraph } from "@/components/crucible/telegraph";

export default function TelegraphDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full flex-col items-center justify-center gap-6 overflow-hidden rounded-xl bg-neutral-950 px-6 py-10">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_50%_0%,rgba(255,255,255,0.04),transparent_65%)]"
      />
      <Telegraph className="relative z-10" />
      <p className="relative z-10 text-center text-sm text-neutral-500">
        Scroll it into view — the session types itself, then rests.
      </p>
    </div>
  );
}
```

## Accessibility & performance

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

## Source (`telegraph.tsx`)

```tsx
"use client";

import * as React from "react";

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

/* ------------------------------------------------------------------ types */

/**
 * Coloring role for a run of terminal text. Everything defaults to a warm
 * off-white; the semantic tints are deliberately quiet so a session reads as
 * one calm block, not a rainbow.
 */
export type TelegraphTone =
  | "default"
  | "muted"
  | "accent"
  | "success"
  | "info"
  | "warn"
  | "error"
  | "string";

export interface TelegraphSegment {
  /** Literal text of this run. */
  text: string;
  /** Coloring role. @default "default" */
  tone?: TelegraphTone;
}

export interface TelegraphLine {
  /**
   * `"command"` lines type out character-by-character behind a prompt sigil;
   * `"output"` lines fade in whole, like a program's response.
   */
  kind: "command" | "output";
  /**
   * Prompt sigil shown before a command line (overrides the component
   * `prompt`). Ignored for output lines.
   */
  prompt?: string;
  /**
   * The line body. A plain string is treated as one default-toned run; pass an
   * array to color individual segments. An empty command line is a resting
   * prompt (the caret parks there when the session finishes).
   */
  content: string | TelegraphSegment[];
}

export interface TelegraphProps
  extends Omit<React.ComponentPropsWithoutRef<"figure">, "content" | "children"> {
  /** The scripted session, top to bottom. @default a short install/deploy session */
  lines?: TelegraphLine[];
  /** Window title shown in the title bar. @default "zsh — ~/my-app" */
  title?: string;
  /** Default prompt sigil for command lines. @default "$" */
  prompt?: string;
  /**
   * The single cool accent — used for the prompt sigil, the caret glow, and
   * the `accent`/`string` tones. Any CSS color. @default sky "#7dd3fc"
   */
  accent?: string;
  /** Typing-rate multiplier (and reveal cadence). @default 1 */
  speed?: number;
  /** Seconds to wait after scrolling into view before typing begins. @default 0.3 */
  startDelay?: number;
  /** Replay the session on a loop instead of resting at the end. @default false */
  loop?: boolean;
  /** Freeze at the fully rendered final transcript, no typing. @default false */
  paused?: boolean;
}

/* --------------------------------------------------------------- defaults */

/** Sky-300 — cool, calm, elegant on charcoal. Not the green every clone uses. */
const DEFAULT_ACCENT = "#7dd3fc";
const DEFAULT_TITLE = "zsh — ~/my-app";

const DEFAULT_LINES: TelegraphLine[] = [
  { kind: "command", content: [{ text: "npm create app@latest my-app" }] },
  {
    kind: "output",
    content: [
      { text: "✓", tone: "success" },
      { text: " scaffolded " },
      { text: "my-app", tone: "string" },
      { text: " (react + vite)", tone: "muted" },
    ],
  },
  { kind: "command", content: [{ text: "cd my-app && npm install" }] },
  {
    kind: "output",
    content: [{ text: "added 214 packages in 3.1s", tone: "muted" }],
  },
  { kind: "command", content: [{ text: "npm run deploy" }] },
  {
    kind: "output",
    content: [
      { text: "▸", tone: "info" },
      { text: " building production bundle…", tone: "muted" },
    ],
  },
  {
    kind: "output",
    content: [
      { text: "✓", tone: "success" },
      { text: " build complete " },
      { text: "— 1.24 MB gzipped", tone: "muted" },
    ],
  },
  {
    kind: "output",
    content: [
      { text: "✓", tone: "success" },
      { text: " live at " },
      { text: "https://my-app.example.com", tone: "string" },
    ],
  },
  { kind: "command", content: "" },
];

/* --------------------------------------------------------------- timing */

const BASE_CPS = 42; // characters per second at speed = 1
const COMMAND_HOLD = 0.42; // pause after a command finishes typing, before output
const OUTPUT_STEP = 0.16; // gap between successive line reveals
const END_HOLD = 1.8; // rest at the end before a loop restarts

/* --------------------------------------------------------------- helpers */

function toneColor(tone: TelegraphTone = "default"): string {
  switch (tone) {
    case "muted":
      return "rgba(245,243,237,0.42)";
    case "accent":
      return "var(--tg-accent)";
    case "success":
      return "#7ee0b0";
    case "info":
      return "#9ec5fb";
    case "warn":
      return "#f2c879";
    case "error":
      return "#f0a1a1";
    case "string":
      return "color-mix(in oklab, var(--tg-accent) 58%, white)";
    default:
      return "rgba(245,243,237,0.92)";
  }
}

function normalize(content: string | TelegraphSegment[]): TelegraphSegment[] {
  if (typeof content === "string") return content ? [{ text: content }] : [];
  return content;
}

function lineLength(segments: TelegraphSegment[]): number {
  return segments.reduce((n, s) => n + s.text.length, 0);
}

/** First `count` characters across the segments, preserving each segment's tone. */
function sliceSegments(segments: TelegraphSegment[], count: number): TelegraphSegment[] {
  const out: TelegraphSegment[] = [];
  let remaining = count;
  for (const seg of segments) {
    if (remaining <= 0) break;
    const take = Math.min(seg.text.length, remaining);
    out.push(take === seg.text.length ? seg : { ...seg, text: seg.text.slice(0, take) });
    remaining -= take;
  }
  return out;
}

interface Scheduled {
  segments: TelegraphSegment[];
  kind: "command" | "output";
  prompt: string;
  length: number;
  /** When this line begins, in seconds from session start. */
  start: number;
  /** Seconds to type this line's characters (0 for output). */
  typeDuration: number;
}

/* ------------------------------------------------------------- component */

/**
 * Telegraph — a self-contained terminal that types a scripted session on its
 * own. macOS-style window chrome frames a charcoal screen; command lines type
 * out character-by-character behind a cool prompt sigil with a blinking block
 * caret, and output lines fade in whole, auto-sequenced so each waits for the
 * one before it. Playback starts when the terminal scrolls into view and runs
 * a single rAF that only ticks the active line — it pauses offscreen and on
 * hidden tabs. Every glyph is real, selectable DOM text; a screen reader hears
 * the finished transcript once. Under reduced motion (or `paused`) the whole
 * session renders statically with no typing.
 */
export function Telegraph({
  lines = DEFAULT_LINES,
  title = DEFAULT_TITLE,
  prompt = "$",
  accent = DEFAULT_ACCENT,
  speed = 1,
  startDelay = 0.3,
  loop = false,
  paused = false,
  className,
  style,
  ...props
}: TelegraphProps) {
  const reducedMotion = useReducedMotion();
  const still = reducedMotion || paused;

  // Build the deterministic timeline once per lines/speed/prompt change.
  const { schedule, total } = React.useMemo(() => {
    const cps = BASE_CPS * Math.max(speed, 0.05);
    let cursor = 0;
    const out: Scheduled[] = lines.map((line) => {
      const segments = normalize(line.content);
      const length = lineLength(segments);
      const start = cursor;
      let typeDuration = 0;
      if (line.kind === "command") {
        typeDuration = length / cps;
        cursor = start + typeDuration + COMMAND_HOLD / Math.max(speed, 0.05);
      } else {
        cursor = start + OUTPUT_STEP / Math.max(speed, 0.05);
      }
      return {
        segments,
        kind: line.kind,
        prompt: line.prompt ?? prompt,
        length,
        start,
        typeDuration,
      };
    });
    return { schedule: out, total: cursor };
  }, [lines, speed, prompt]);

  const count = schedule.length;

  // Current frame: which line is active, how many of its chars are shown,
  // and whether the session has completed. Kept minimal so the rAF only
  // re-renders when the visible output actually changes.
  const [frame, setFrame] = React.useState({ visible: 0, chars: 0, done: false });
  const [finished, setFinished] = React.useState(false);
  const sigRef = React.useRef("");

  const tick = React.useCallback(
    (elapsed: number) => {
      let t = elapsed - startDelay;
      let done = false;
      if (loop) {
        t = ((t % (total + END_HOLD)) + total + END_HOLD) % (total + END_HOLD);
      }
      if (t < 0) t = 0;
      if (!loop && t >= total) done = true;

      // Last line whose start has passed.
      let visible = 0;
      for (let i = 0; i < count; i++) {
        if (schedule[i].start <= t) visible = i;
        else break;
      }

      const active = schedule[visible];
      let chars = active.length;
      if (active.kind === "command" && t < active.start + active.typeDuration) {
        chars = Math.max(0, Math.floor((t - active.start) * BASE_CPS * Math.max(speed, 0.05)));
      }

      const sig = `${visible}:${chars}:${done ? 1 : 0}`;
      if (sig !== sigRef.current) {
        sigRef.current = sig;
        setFrame({ visible, chars, done });
      }
      if (done && !loop && !finished) setFinished(true);
    },
    [schedule, total, count, startDelay, speed, loop, finished]
  );

  // Single rAF, view-gated + tab-gated by the hook. It stops entirely once the
  // (non-looping) session has finished — the resting caret blink is pure CSS.
  const containerRef = useVisibilityPause<HTMLElement>(tick, {
    paused: still || (finished && !loop),
  });

  // Reset when the source or speed changes so a new session replays cleanly.
  React.useEffect(() => {
    sigRef.current = "";
    setFrame({ visible: 0, chars: 0, done: false });
    setFinished(false);
  }, [schedule]);

  const transcript = React.useMemo(
    () =>
      schedule
        .map((l) => {
          const body = l.segments.map((s) => s.text).join("");
          return l.kind === "command" ? `${l.prompt} ${body}`.trimEnd() : body;
        })
        .join("\n"),
    [schedule]
  );

  const restIndex = lastCommandIndex(schedule);

  const renderLine = (
    line: Scheduled,
    segments: TelegraphSegment[],
    caret: boolean,
    fade: boolean,
    key: number
  ) => (
    <div key={key} className={cn("whitespace-pre", fade && !still && "tg-in")}>
      {line.kind === "command" && (
        <span className="tg-sigil" aria-hidden>
          {line.prompt}{" "}
        </span>
      )}
      {segments.map((seg, i) => (
        <span key={i} style={{ color: toneColor(seg.tone) }}>
          {seg.text}
        </span>
      ))}
      {caret && <span className="tg-caret" aria-hidden />}
    </div>
  );

  // The visible screen. Static (reduced motion / paused) renders the whole
  // transcript with the caret parked on the final command line.
  const body = still
    ? schedule.map((line, i) => {
        const lastCommand = line.kind === "command" && i === restIndex;
        return renderLine(line, line.segments, lastCommand, false, i);
      })
    : schedule.slice(0, frame.visible + 1).map((line, i) => {
        const isActive = i === frame.visible;
        const fullyTyped = !isActive || line.kind === "output" || frame.chars >= line.length;
        const shown =
          isActive && line.kind === "command" && !fullyTyped
            ? sliceSegments(line.segments, frame.chars)
            : line.segments;
        // Caret rides the active command line while typing/holding; when the
        // session is done it parks on the final command line.
        const caret =
          line.kind === "command" && (isActive || (frame.done && i === restIndex));
        const fade = line.kind === "output";
        return renderLine(line, shown, caret, fade, i);
      });

  const lineBoxHeight = `${count * 1.55 + 1.5}rem`;

  return (
    <figure
      ref={containerRef as React.RefObject<HTMLElement>}
      data-crucible="telegraph"
      aria-label={`Terminal session: ${title}`}
      className={cn(
        "not-prose w-full max-w-xl overflow-hidden rounded-xl border border-white/10",
        "bg-neutral-900/85 shadow-[0_28px_70px_-30px_rgba(0,0,0,0.85)] backdrop-blur-sm",
        className
      )}
      style={{ ["--tg-accent" as string]: accent, ...style }}
      {...props}
    >
      <style>{`
        [data-crucible="telegraph"] .tg-sigil {
          color: var(--tg-accent);
          text-shadow: 0 0 10px color-mix(in oklab, var(--tg-accent) 45%, transparent);
        }
        [data-crucible="telegraph"] .tg-caret {
          display: inline-block;
          width: 0.6em;
          height: 1.05em;
          margin-left: 0.06em;
          vertical-align: text-bottom;
          background: rgba(245,243,237,0.9);
          box-shadow: 0 0 8px 1px color-mix(in oklab, var(--tg-accent) 40%, transparent);
          animation: tg-blink 1.05s steps(1) infinite;
        }
        [data-crucible="telegraph"] .tg-in {
          animation: tg-in 0.32s ease-out both;
        }
        @keyframes tg-blink { 0%,52% { opacity: 1 } 53%,100% { opacity: 0 } }
        @keyframes tg-in { from { opacity: 0; transform: translateY(3px) } to { opacity: 1; transform: none } }
        @media (prefers-reduced-motion: reduce) {
          [data-crucible="telegraph"] .tg-caret { animation: none; opacity: 0.5 }
          [data-crucible="telegraph"] .tg-in { animation: none }
        }
      `}</style>

      {/* Title bar with macOS-style controls (neutral, brand-agnostic). */}
      <figcaption className="flex items-center gap-2 border-b border-white/8 bg-white/2 px-3.5 py-2.5">
        <span aria-hidden className="flex gap-1.5">
          <span className="h-3 w-3 rounded-full bg-white/15" />
          <span className="h-3 w-3 rounded-full bg-white/15" />
          <span className="h-3 w-3 rounded-full bg-white/15" />
        </span>
        <span className="mx-auto truncate px-2 font-mono text-xs text-white/40">{title}</span>
        <span aria-hidden className="h-3 w-3" />
      </figcaption>

      {/* Screen-reader transcript — read once; the animated screen is decorative. */}
      <span className="sr-only">{transcript}</span>

      <div
        aria-hidden
        className="overflow-x-auto px-4 py-3.5 font-mono text-[13px] leading-[1.55] sm:px-5 sm:py-4"
        style={{ minHeight: lineBoxHeight }}
      >
        {body}
      </div>
    </figure>
  );
}

/** Index of the last command line (where the resting caret parks), or -1. */
function lastCommandIndex(schedule: Scheduled[]): number {
  for (let i = schedule.length - 1; i >= 0; i--) {
    if (schedule[i].kind === "command") return i;
  }
  return -1;
}
```
