A CI pipeline with every step listed up front as pending. Logs stream into the running step, pinned to the newest line, and each step keeps its time. Turn on Break a test: the run stops at the tests, which turn red and stay open, and the steps after them never start.
@keyframeui
registry/demos/thought-trail-deploy-demo.tsx
"use client";
import * as React from "react";
import { useInView } from "motion/react";
import { cn } from "@/lib/utils";
import { ThoughtTrail, ThoughtTrailStep } from "@/components/ui/thought-trail";
type Stage = {
id: string;
title: string;
summary: string;
ms: number;
log: string[];
/** With "Break a test" on: the log it stops on, and its summary. */
fails?: { log: string[]; summary: string };
};
const stages: Stage[] = [
{
id: "clone",
title: "Cloning repository",
summary: "Cloned 8f3c2a1",
ms: 1400,
log: [
"Cloning github.com/acme/storefront (branch: main)",
"HEAD is now at 8f3c2a1 Fix cart badge overflow",
"Cloned in 1.1s",
],
},
{
id: "install",
title: "Installing dependencies",
summary: "Installed 1,284 packages",
ms: 3000,
log: [
"$ npm ci",
"npm warn deprecated inflight@1.0.6",
"added 1284 packages in 2.7s",
"214 packages are looking for funding",
"found 0 vulnerabilities",
],
},
{
id: "build",
title: "Building",
summary: "Built 42 routes",
ms: 4200,
log: [
"$ next build",
"▲ Next.js 16.4.0",
"Creating an optimized production build …",
"✓ Compiled successfully in 2.9s",
"✓ Linting and checking validity of types",
"✓ Collecting page data",
"✓ Generating static pages (42/42)",
"Route (app) Size First Load JS",
"┌ ○ / 4.1 kB 118 kB",
"├ ○ /cart 2.7 kB 116 kB",
"├ ● /products/[slug] 3.4 kB 117 kB",
"└ ○ /checkout 5.2 kB 119 kB",
],
},
{
id: "test",
title: "Running tests",
summary: "212 tests passed",
ms: 2800,
log: [
"$ vitest run",
" ✓ cart/badge.test.ts (12)",
" ✓ cart/totals.test.ts (31)",
" ✓ checkout/address.test.ts (24)",
" ✓ products/search.test.ts (58)",
" Test Files 38 passed (38)",
" Tests 212 passed (212)",
],
fails: {
summary: "1 of 212 tests failed",
log: [
"$ vitest run",
" ✓ cart/totals.test.ts (31)",
" ✗ cart/badge.test.ts > caps the count at 99+",
' Expected: "99+"',
' Received: "100"',
" Test Files 1 failed | 37 passed (38)",
" Tests 1 failed | 211 passed (212)",
],
},
},
{
id: "deploy",
title: "Deploying to production",
summary: "Live at storefront.acme.app",
ms: 2200,
log: [
"Uploading build output (18.4 MB)",
"Promoting to production",
"Assigning storefront.acme.app",
"✓ Ready in 2.1s",
],
},
];
const LEAD = 700; // ms the pipeline sits queued before the first step
const starts = stages.map((_, i) => LEAD + stages.slice(0, i).reduce((sum, s) => sum + s.ms, 0));
const END = LEAD + stages.reduce((sum, s) => sum + s.ms, 0);
const testIndex = stages.findIndex((s) => s.fails);
const FAIL_AT = starts[testIndex] + stages[testIndex].ms; // the broken run stops here
/** Milliseconds into the current run, from when the demo first scrolls into view. Stops at `end`. */
function usePlayhead(run: number, playing: boolean, end: number) {
const [clock, setClock] = React.useState({ run, t: 0 });
React.useEffect(() => {
if (!playing) return;
const start = performance.now();
const timer = window.setInterval(() => {
const t = Math.min(end, performance.now() - start);
setClock({ run, t });
if (t >= end) window.clearInterval(timer);
}, 50);
return () => window.clearInterval(timer);
}, [run, playing, end]);
return clock.run === run ? clock.t : 0;
}
const seconds = (ms: number) => `${(ms / 1000).toFixed(1)}s`;
// A CI pipeline with every step listed up front. Logs stream into the step
// that's running and stay pinned to the newest line; each step keeps its
// time. Turn on "Break a test" and the run stops at the tests, with the
// failure left open.
export function ThoughtTrailDeployDemo() {
const ref = React.useRef<HTMLDivElement>(null);
const playing = useInView(ref, { once: true, amount: 0.4 });
const [broken, setBroken] = React.useState(false);
const [run, setRun] = React.useState(0);
const end = broken ? FAIL_AT : END;
const t = usePlayhead(run, playing, end);
const redeploy = (nextBroken = broken) => {
setBroken(nextBroken);
setRun((r) => r + 1);
};
return (
<div
ref={ref}
className="flex h-[32rem] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm shadow-zinc-950/5 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-black/40"
>
<div className="flex flex-wrap items-center gap-x-4 gap-y-3 border-b border-zinc-100 px-5 py-3.5 dark:border-zinc-800/80">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span
aria-hidden
className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-900 text-sm font-semibold text-white dark:bg-zinc-100 dark:text-zinc-900"
>
a
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">acme/storefront</p>
<p className="flex min-w-0 items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
<span className="rounded bg-zinc-100 px-1 font-mono text-[11px] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
main
</span>
<span className="truncate">8f3c2a1 · Fix cart badge overflow</span>
</p>
</div>
</div>
<div className="flex items-center gap-1">
<label className="inline-flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2.5 text-xs font-medium text-zinc-600 select-none hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800">
<input
type="checkbox"
checked={broken}
onChange={(e) => redeploy(e.target.checked)}
className="peer sr-only"
/>
<span
aria-hidden
className="relative h-4 w-7 rounded-full bg-zinc-200 transition-colors duration-200 peer-checked:bg-rose-500 peer-focus-visible:ring-2 peer-focus-visible:ring-zinc-400 dark:bg-zinc-700 dark:peer-checked:bg-rose-500 dark:peer-focus-visible:ring-zinc-600"
>
<span
className={cn(
"absolute top-0.5 left-0.5 size-3 rounded-full bg-white shadow-sm transition-transform duration-200",
broken && "translate-x-3",
)}
/>
</span>
Break a test
</label>
<button
type="button"
onClick={() => redeploy()}
className="inline-flex h-8 items-center rounded-lg px-2.5 text-xs font-medium text-zinc-600 transition-[background-color,color,scale] duration-150 outline-none select-none hover:bg-zinc-100 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-400 active:scale-[0.96] dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-600"
>
Redeploy
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{playing ? (
<ThoughtTrail
running={t < end}
label="Deploying"
collapseOnDone={false}
doneLabel={({ seconds, failed }) => `${failed ? "Failed after" : "Deployed in"} ${seconds ?? 0}s`}
>
{stages.map((stage, i) => {
const start = starts[i];
const fails = broken && stage.fails;
const ms = stage.ms;
// A broken run never reaches the steps after the tests.
const skipped = broken && i > testIndex;
const status =
skipped || t < start ? "pending" : t < start + ms ? "active" : fails ? "error" : "done";
const log = fails ? stage.fails!.log : stage.log;
const shown = Math.ceil(Math.min(1, (t - start) / (ms * 0.9)) * log.length);
return (
<ThoughtTrailStep
key={stage.id}
status={status}
title={stage.title}
summary={fails ? stage.fails!.summary : stage.summary}
meta={status === "pending" ? undefined : seconds(Math.min(t - start, ms))}
contentClassName="max-h-32 overflow-y-auto pt-3 [mask-image:linear-gradient(to_bottom,transparent,#000_0.75rem)]"
>
<pre className="font-mono text-[11.5px] leading-5 whitespace-pre">
{log.slice(0, shown).map((line, j) => (
<span
key={j}
className={cn(
"block",
line.includes("✓") && "text-emerald-700 dark:text-emerald-400",
(line.includes("✗") || line.includes("failed") || line.includes("Received")) &&
"text-rose-600 dark:text-rose-400",
)}
>
{line}
</span>
))}
</pre>
</ThoughtTrailStep>
);
})}
</ThoughtTrail>
) : null}
</div>
</div>
);
}
Live preview, examples and props: https://keyframeui.com/components/thought-trail#thought-trail-deploy-demo




























