Agent playback
Your AI product working, hands-off and on a loop: the hero demo built from the real components.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/agent-playback.jsonOverview
A screen recording of your product goes stale the day you ship, and a static mock looks like one. This plays a scripted conversation through the real components instead. The prompt types itself into the composer with a person’s uneven rhythm, then lifts out and glides into the message bubble. The agent’s work arrives block by block, each opening its own room so everything above glides up: a thinking trace ticking off steps, web research stacking its sources, a tool call filling in its arguments. The answer streams in with its citations, holds a beat, then the conversation dissolves and the next prompt starts. It pauses off-screen and in a background tab, has its own pause button, and with reduced motion shows the finished conversation, still.
States
- typing
- The prompt types in at 26–60ms a character, pausing after words and punctuation.
- sent
- The text glides from the composer into its bubble (620ms) as Send becomes a spinner.
- working
- Each step’s block opens its own height (520ms) and blurs in: thinking, search, tool call, in your script’s order.
- answering
- The answer streams a few words at a time; its actions fold in once it’s done.
- next
- After hold ms the conversation lifts and dissolves (480ms) and the next turn begins; it loops unless loop is false.
- paused
- Off-screen, in a hidden tab, with the pause button or the paused prop. Waits resume exactly where they stopped.
Usage
import { AgentPlayback, type PlaybackTurn } from "@/components/agent-playback";
const turns: PlaybackTurn[] = [
{
prompt: "Summarise this week's churn",
steps: [
{ type: "thinking", steps: ["Reading the events", "Grouping by plan"] },
{ type: "tool", name: "run_query", input: { table: "events", since: "7d" }, output: { rows: 1284 } },
{ type: "answer", content: "Churn fell to 2.1% this week, mostly on the Starter plan." },
],
},
];
<AgentPlayback turns={turns} header="Acme" />Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.
Works with
Installs with it
- Prompt composerThe input that starts it all: grows as you type, takes files, and turns Send into Stop while answering.
- Thinking traceAn expandable record of what an agent did: steps, reasoning, search or tool calls.
- Web researchWatch the agent search: the query types in, page icons stack up as it reads, then it folds into a trail of searches and the pages each one found.
- Tool callOne tool invocation from arguments to result: preparing, running, done, failed or denied.
- Streaming answerAn answer as it arrives, with inline citations, actions, sources and follow-ups.
In a hero section
Beside your headline it shows the product doing the job, so the page explains itself.
import { AgentPlayback } from "@/components/agent-playback";
import { turns } from "./demo-script";
export function Hero() {
return (
<section className="mx-auto grid max-w-6xl items-center gap-12 px-6 py-24 lg:grid-cols-2">
<div>
<h1 className="text-5xl font-medium tracking-[-0.04em]">Answers your ops team can act on</h1>
<p className="mt-4 text-lg text-muted-foreground">Ask in plain words. Relay reads, checks and does the work.</p>
</div>
<AgentPlayback turns={turns} header="Relay" height={560} />
</section>
);
}Props
| Prop | Type | Description |
|---|---|---|
| turns | PlaybackTurn[] | Each a prompt and its steps: thinking, search, tool and answer, in any order and number. |
| speed | numberdefault 1 | Pace; 2 plays twice as fast. |
| hold | numberdefault 4200 | How long a finished answer stays before the next prompt (ms). |
| loop | booleandefault true | Start again after the last turn. |
| paused | booleandefault false | Pause from outside, e.g. while a dialog is open. |
| header / placeholder | ReactNode / string | A title bar (your product’s name) and the composer’s placeholder. |
| height | numberdefault 540 | Fixed, so the page around it never moves; the conversation grows up from the composer and fades at the top. |
Accessibility and motion
- The moving copy is inert and hidden from screen readers; a sentence describes what the demo shows instead. The pause button meets the rule that anything moving for more than five seconds can be paused.
- It pauses when less than 15% is on screen or the tab is hidden, and resumes from the exact moment it stopped; live timers skip the paused time.
- The script restarts only when its prompts change, so an inline array in a component that re-renders is fine.
- With reduced motion it shows the first conversation finished and holds still.
- Installs the composer, thinking trace, web research, tool call and streaming answer it plays.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
The CLI also installs Prompt composer, Thinking trace, Web research, Tool call, Streaming answer.
Source
›Show the full source of agent-playback.tsx (484 lines)
"use client";
import * as React from "react";
import { Pause, Play } from "lucide-react";
import { PromptComposer, type ComposerStatus } from "./prompt-composer";
import { AnswerActions, StreamingText, type AnswerSegment, type AnswerSource } from "./streaming-answer";
import { ThinkingTrace } from "./thinking-trace";
import { ToolCall, type ToolCallStatus } from "./tool-call";
import { WebResearch, type ResearchSource, type ResearchStatus } from "./web-research";
/* ─────────────────────────────────────────────────────────
* AGENT PLAYBACK: your product, working, on a loop
*
* typing the prompt types itself into the composer, with
* a person's uneven rhythm
* sent the text lifts out of the composer and glides
* into the message bubble; Send becomes a spinner
* working the agent's blocks arrive one by one, each opening
* its own room so what's above glides up: a thinking
* trace ticking off steps, web research stacking
* sources, a tool call filling in its arguments
* answering the answer streams in word by word, citations and
* all, then its actions settle in underneath
* next a beat to read it, then the conversation dissolves
* and the next prompt starts typing
*
* Built from the real components (composer, thinking trace, web
* research, tool call, streaming answer), so the hero shows
* exactly what the product does. It pauses off-screen and in a
* hidden tab, has its own pause button, and with reduced motion
* shows the finished first conversation, still.
* ───────────────────────────────────────────────────────── */
export type PlaybackStep =
| { type: "thinking"; steps: string[]; label?: string; variant?: "steps" | "reasoning" }
| { type: "search"; queries: { query: string; sources: ResearchSource[] }[] }
| {
type: "tool";
name: string;
title?: string;
icon?: React.ReactNode;
/** Fills in field by field, as a model writes the arguments. */
input: Record<string, unknown>;
output?: unknown;
renderOutput?: (output: unknown) => React.ReactNode;
/** How long it runs (ms); shown as its run time. */
duration?: number;
/** Show the arguments and result. Default true. */
open?: boolean;
}
| { type: "answer"; content: AnswerSegment[] | string; sources?: AnswerSource[] };
export interface PlaybackTurn {
prompt: string;
steps: PlaybackStep[];
}
export interface AgentPlaybackProps extends React.HTMLAttributes<HTMLDivElement> {
/** The conversations to play, in order. */
turns: PlaybackTurn[];
/** Pace: 2 plays twice as fast. */
speed?: number;
/** How long a finished answer stays before the next prompt (ms). */
hold?: number;
/** Start again after the last turn. */
loop?: boolean;
/** Pause from outside, e.g. while a dialog is open. */
paused?: boolean;
placeholder?: string;
/** Above the conversation, e.g. your product's name. */
header?: React.ReactNode;
/** The window's height; it never changes, so the page around it never moves. */
height?: number;
}
type Block =
| { id: number; type: "thinking"; labels: string[]; status: "running" | "done"; startedAt: number; label?: string; variant: "steps" | "reasoning" }
| { id: number; type: "search"; status: ResearchStatus; queries: string[]; sources: ResearchSource[] }
| { id: number; type: "tool"; step: Extract<PlaybackStep, { type: "tool" }>; status: ToolCallStatus; input: Record<string, unknown> }
| { id: number; type: "answer"; content: AnswerSegment[]; status: "streaming" | "done"; sources?: AnswerSource[] };
type View = { turn: number; draft: string; sent: string | null; composer: ComposerStatus; blocks: Block[]; leaving: boolean };
const MORPH = "cubic-bezier(0.16,1,0.3,1)";
const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";
const EMPTY: View = { turn: 0, draft: "", sent: null, composer: "ready", blocks: [], leaving: false };
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */
const swap = (on: boolean, reduced: boolean): React.CSSProperties => ({
opacity: on ? 1 : 0,
transform: on ? "none" : "scale(0.6)",
filter: on ? "none" : "blur(3px)",
transition: reduced ? "none" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,
});
/** Words and spaces as separate tokens, citations whole, so an answer can stream a few at a time. */
function tokens(content: AnswerSegment[] | string): AnswerSegment[] {
const parts: AnswerSegment[] = typeof content === "string" ? [content] : content;
return parts.flatMap((p): AnswerSegment[] => (typeof p === "string" ? p.split(/(\s+)/).filter(Boolean) : [p]));
}
/** The finished state of a turn, for reduced motion. */
function settled(turn: PlaybackTurn, index: number): View {
let id = 0;
const blocks: Block[] = turn.steps.map((step) => {
id += 1;
if (step.type === "thinking") return { id, type: "thinking", labels: step.steps, status: "done", startedAt: 0, label: step.label, variant: step.variant ?? "steps" };
if (step.type === "search")
return { id, type: "search", status: "done", queries: step.queries.map((q) => q.query), sources: step.queries.flatMap((q) => q.sources) };
if (step.type === "tool") return { id, type: "tool", step, status: "done", input: step.input };
return { id, type: "answer", content: tokens(step.content), status: "done", sources: step.sources };
});
return { turn: index, draft: "", sent: turn.prompt, composer: "ready", blocks, leaving: false };
}
/**
* A clock whose waits can be paused mid-way and resumed for the time that was left,
* so going off-screen (or pressing pause) freezes the story exactly where it is.
*/
function createClock() {
let paused = false;
let wait: { resolve: () => void; left: number; since: number; timer: number } | null = null;
const start = () => {
if (!wait) return;
wait.since = performance.now();
wait.timer = window.setTimeout(() => {
const done = wait?.resolve;
wait = null;
done?.();
}, Math.max(0, wait.left));
};
return {
sleep(ms: number) {
return new Promise<void>((resolve) => {
wait = { resolve, left: ms, since: 0, timer: 0 };
if (!paused) start();
});
},
pause() {
if (paused) return;
paused = true;
if (wait?.timer) {
window.clearTimeout(wait.timer);
wait.left -= performance.now() - wait.since;
wait.timer = 0;
}
},
resume() {
if (!paused) return;
paused = false;
if (wait && !wait.timer) start();
},
stop() {
if (wait?.timer) window.clearTimeout(wait.timer);
wait = null;
},
};
}
/** A block that opens its own room as it arrives, so everything above glides up instead of jumping. */
function Arrive({ reduced, children }: { reduced: boolean; children: React.ReactNode }) {
const [open, setOpen] = React.useState(reduced);
React.useEffect(() => {
if (open) return;
const frame = requestAnimationFrame(() => requestAnimationFrame(() => setOpen(true)));
return () => cancelAnimationFrame(frame);
}, [open]);
return (
<div
className="grid"
style={{
gridTemplateRows: open ? "1fr" : "0fr",
opacity: open ? 1 : 0,
transform: open ? "none" : "translateY(8px)",
filter: open ? "none" : "blur(4px)",
transition: reduced ? "none" : `grid-template-rows 520ms ${MORPH}, opacity 420ms ${MORPH} 80ms, transform 520ms ${MORPH}, filter 420ms ${MORPH} 80ms`,
}}
>
<div className="min-h-0">{children}</div>
</div>
);
}
export function AgentPlayback({
turns,
speed = 1,
hold = 4200,
loop = true,
paused = false,
placeholder = "Ask anything",
header,
height = 540,
className = "",
...props
}: AgentPlaybackProps) {
const reduced = useReducedMotion();
const [view, setView] = React.useState<View>(EMPTY);
const [userPaused, setUserPaused] = React.useState(false);
const [offscreen, setOffscreen] = React.useState(false);
const [hidden, setHidden] = React.useState(false);
const rootRef = React.useRef<HTMLDivElement>(null);
const composerRef = React.useRef<HTMLDivElement>(null);
const bubbleRef = React.useRef<HTMLDivElement>(null);
const fromRect = React.useRef<DOMRect | null>(null);
const clock = React.useMemo(createClock, []);
const pausedAt = React.useRef<number | null>(null);
const halted = paused || userPaused || offscreen || hidden;
// Restart only when the script itself changes, so an inline array in a parent that re-renders is fine.
const latest = React.useRef(turns);
React.useLayoutEffect(() => {
latest.current = turns;
});
const scriptKey = turns.map((t) => `${t.prompt}\u0000${t.steps.length}`).join("\u0001");
// Off-screen or in a background tab, the story waits.
React.useEffect(() => {
const el = rootRef.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => setOffscreen(!entry.isIntersecting), { threshold: 0.15 });
observer.observe(el);
const onVisibility = () => setHidden(document.visibilityState === "hidden");
onVisibility();
document.addEventListener("visibilitychange", onVisibility);
return () => {
observer.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
// Pausing freezes the clock; on resume, live timers skip the time they were paused for.
React.useEffect(() => {
if (halted) {
clock.pause();
pausedAt.current ??= Date.now();
return;
}
const since = pausedAt.current;
pausedAt.current = null;
clock.resume();
if (since !== null) {
const gap = Date.now() - since;
setView((v) => ({ ...v, blocks: v.blocks.map((b) => (b.type === "thinking" && b.status === "running" ? { ...b, startedAt: b.startedAt + gap } : b)) }));
}
}, [halted, clock]);
// The script.
React.useEffect(() => {
const turns = latest.current;
if (!turns.length) return;
if (reduced) {
setView(settled(turns[0], 0));
return;
}
let alive = true;
let id = 0;
const pace = (ms: number) => clock.sleep(ms / Math.max(0.1, speed));
const update = (fn: (v: View) => View) => alive && setView(fn);
const patch = (blockId: number, fn: (b: Block) => Block) => update((v) => ({ ...v, blocks: v.blocks.map((b) => (b.id === blockId ? fn(b) : b)) }));
const play = async () => {
for (let t = 0; alive; t = (t + 1) % turns.length) {
const turn = turns[t];
update(() => ({ ...EMPTY, turn: t }));
await pace(700);
// A person typing: quick runs, small pauses after words and punctuation.
for (let i = 1; i <= turn.prompt.length && alive; i++) {
update((v) => ({ ...v, draft: turn.prompt.slice(0, i) }));
const ch = turn.prompt[i - 1];
await pace(/[,.?!]/.test(ch) ? 180 : ch === " " ? 70 + Math.random() * 60 : 26 + Math.random() * 34);
}
if (!alive) return;
await pace(420);
// Send: note where the text sits in the composer so the bubble can glide out of it.
const field = composerRef.current?.querySelector("textarea");
fromRect.current = field?.getBoundingClientRect() ?? null;
update((v) => ({ ...v, draft: "", sent: turn.prompt, composer: "submitted" }));
await pace(650);
for (const step of turn.steps) {
if (!alive) return;
const blockId = ++id;
if (step.type === "thinking") {
update((v) => ({
...v,
composer: "streaming",
blocks: [...v.blocks, { id: blockId, type: "thinking", labels: [], status: "running", startedAt: Date.now(), label: step.label, variant: step.variant ?? "steps" }],
}));
for (const label of step.steps) {
await pace(650 + Math.random() * 350);
patch(blockId, (b) => (b.type === "thinking" ? { ...b, labels: [...b.labels, label] } : b));
}
await pace(900);
patch(blockId, (b) => (b.type === "thinking" ? { ...b, status: "done" } : b));
await pace(700);
} else if (step.type === "search") {
update((v) => ({ ...v, composer: "streaming", blocks: [...v.blocks, { id: blockId, type: "search", status: "searching", queries: [], sources: [] }] }));
for (const group of step.queries) {
patch(blockId, (b) => (b.type === "search" ? { ...b, status: "searching", queries: [...b.queries, group.query] } : b));
await pace(1150);
for (const source of group.sources) {
patch(blockId, (b) => (b.type === "search" ? { ...b, status: "reading", sources: [...b.sources, source] } : b));
await pace(360 + Math.random() * 160);
}
await pace(300);
}
patch(blockId, (b) => (b.type === "search" ? { ...b, status: "done" } : b));
await pace(650);
} else if (step.type === "tool") {
update((v) => ({ ...v, composer: "streaming", blocks: [...v.blocks, { id: blockId, type: "tool", step, status: "preparing", input: {} }] }));
for (const [key, value] of Object.entries(step.input)) {
await pace(280 + Math.random() * 140);
patch(blockId, (b) => (b.type === "tool" ? { ...b, input: { ...b.input, [key]: value } } : b));
}
await pace(350);
patch(blockId, (b) => (b.type === "tool" ? { ...b, status: "running" } : b));
await pace(step.duration ?? 1300);
patch(blockId, (b) => (b.type === "tool" ? { ...b, status: "done" } : b));
await pace(800);
} else {
const all = tokens(step.content);
update((v) => ({ ...v, composer: "streaming", blocks: [...v.blocks, { id: blockId, type: "answer", content: [], status: "streaming", sources: step.sources }] }));
await pace(250);
for (let i = 0; i < all.length && alive; ) {
i = Math.min(all.length, i + 1 + Math.floor(Math.random() * 3));
const shown = all.slice(0, i);
patch(blockId, (b) => (b.type === "answer" ? { ...b, content: shown } : b));
await pace(38 + Math.random() * 46);
}
patch(blockId, (b) => (b.type === "answer" ? { ...b, status: "done" } : b));
}
}
update((v) => ({ ...v, composer: "ready" }));
await pace(hold);
if (!loop && t === turns.length - 1) return;
// The conversation dissolves before the next one starts.
update((v) => ({ ...v, leaving: true }));
await pace(520);
}
};
play();
return () => {
alive = false;
clock.stop();
};
}, [scriptKey, speed, hold, loop, reduced, clock]);
// The sent text glides from where it was typed into its bubble.
React.useLayoutEffect(() => {
const bubble = bubbleRef.current;
const from = fromRect.current;
fromRect.current = null;
if (!bubble || !from || reduced) return;
const to = bubble.getBoundingClientRect();
const dx = from.left + 14 - (to.left + 16);
const dy = from.top + 12 - (to.top + 10);
bubble.animate(
[
{ transform: `translate(${dx}px, ${dy}px)`, backgroundColor: "transparent" },
{ transform: "none", backgroundColor: getComputedStyle(bubble).backgroundColor },
],
{ duration: 620, easing: MORPH }
);
}, [view.sent, reduced]);
const turn = turns[view.turn];
return (
<div
ref={rootRef}
role="region"
aria-roledescription="demo"
aria-label="Product demo"
className={`relative flex flex-col overflow-hidden rounded-[22px] border border-border bg-background ${className}`}
style={{ height }}
{...props}
>
{/* What the demo shows, for people who can't watch it; the moving copy is hidden from them. */}
<p className="sr-only">
{turn ? `A looping demo. Someone asks: “${turn.prompt}”, and the assistant works through it and answers.` : ""}
</p>
{header && <div className="flex h-11 shrink-0 items-center border-b border-border px-4 pr-12 text-[13px] font-medium text-foreground">{header}</div>}
<button
type="button"
aria-label={userPaused ? "Play demo" : "Pause demo"}
aria-pressed={userPaused}
onClick={() => setUserPaused((p) => !p)}
className={`absolute right-2.5 top-2 z-10 flex size-7 items-center justify-center rounded-full bg-background text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:text-foreground ${FOCUS}`}
>
<span aria-hidden="true" className="absolute inset-0 flex items-center justify-center" style={swap(!userPaused, reduced)}>
<Pause className="size-3" fill="currentColor" strokeWidth={0} />
</span>
<span aria-hidden="true" className="absolute inset-0 flex items-center justify-center" style={swap(userPaused, reduced)}>
<Play className="size-3 translate-x-px" fill="currentColor" strokeWidth={0} />
</span>
</button>
<div aria-hidden="true" inert className="flex min-h-0 flex-1 flex-col">
{/* The conversation sits on the composer and grows upwards; older lines fade out at the top. */}
<div className="relative min-h-0 flex-1 overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,#000_56px)]">
<div
key={view.turn}
className="absolute inset-x-0 bottom-0 flex flex-col gap-4 px-4 pb-4 sm:px-5"
style={{
opacity: view.leaving ? 0 : 1,
filter: view.leaving ? "blur(6px)" : "none",
transform: view.leaving ? "translateY(-12px)" : "none",
transition: reduced ? "none" : `opacity 480ms ${MORPH}, filter 480ms ${MORPH}, transform 520ms ${MORPH}`,
}}
>
{view.sent && (
// Not wrapped in Arrive: its own glide out of the composer is the entrance.
<div className="flex justify-end">
<div ref={bubbleRef} className="max-w-[85%] whitespace-pre-wrap rounded-[20px] bg-muted px-4 py-2.5 text-[14px] leading-relaxed text-foreground">
{view.sent}
</div>
</div>
)}
{view.blocks.map((b) => (
<Arrive key={b.id} reduced={reduced}>
{b.type === "thinking" ? (
<ThinkingTrace
variant={b.variant}
status={b.status}
label={b.label}
startedAt={reduced ? undefined : b.startedAt}
steps={b.labels.map((label) => ({ label }))}
/>
) : b.type === "search" ? (
<WebResearch status={b.status} queries={b.queries} sources={b.sources} />
) : b.type === "tool" ? (
<ToolCall
name={b.step.name}
title={b.step.title}
icon={b.step.icon}
status={b.status}
input={b.input}
output={b.status === "done" ? b.step.output : undefined}
renderOutput={b.step.renderOutput}
duration={b.step.duration ?? 1300}
defaultOpen={b.step.open ?? true}
/>
) : (
<div className="flex flex-col gap-2">
<StreamingText content={b.content} status={b.status} className="text-[14px]" />
<div
className="grid"
style={{
gridTemplateRows: b.status === "done" ? "1fr" : "0fr",
opacity: b.status === "done" ? 1 : 0,
transition: reduced ? "none" : `grid-template-rows 420ms ${MORPH}, opacity 360ms ${MORPH} 120ms`,
}}
>
<div className="min-h-0 overflow-hidden">
<AnswerActions copyText="" sources={b.sources} />
</div>
</div>
</div>
)}
</Arrive>
))}
</div>
</div>
<div ref={composerRef} className="shrink-0 px-3 pb-3">
<PromptComposer value={view.draft} status={view.composer} placeholder={placeholder} onSubmit={() => {}} onStop={() => {}} />
</div>
</div>
</div>
);
}
Registry item: /r/agent-playback.json