Telegraph
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.
cssfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/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;
}
Installation
CLI
npx shadcn@latest add @crucible/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 all props of Omit<React.ComponentPropsWithoutRef<"figure">, "content" | "children"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.