An amber-on-black terminal that plays a whole deploy. A command is typed, then five stages appear one at a time, each with a braille spinner, a progress bar built from block characters and a percentage. Finished stages get a check, their duration and a one-line note. Beside the log, twelve edge regions switch from wait to sync to live during the rollout, and the run ends on an inverse-video DEPLOYED banner with the live address. On phones the bars shorten and the region grid moves below the log.
import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";
const COMMAND = "tern deploy --prod";
const project = { name: "orchard/storefront", branch: "main", commit: "9f3c2e1", url: "storefront.orchard.test", total: "18.2s" };
/** Pipeline stages. `from` and `to` are milliseconds into the run; `took` is the time printed when done. */
const stages = [
{ label: "Install dependencies", note: "318 packages, lockfile unchanged", took: "1.9s", from: 350, to: 750 },
{ label: "Type check", note: "0 errors in 142 files", took: "2.4s", from: 750, to: 1100 },
{ label: "Build", note: "42 routes, 186 kB first load", took: "8.1s", from: 1100, to: 1750 },
{ label: "Upload assets", note: "7.4 MB sent, 61 files cached", took: "1.6s", from: 1750, to: 2200 },
{ label: "Roll out to edge", note: "12 of 12 regions healthy", took: "4.2s", from: 2200, to: 2850 },
];
/** Regions light up in this order during the last stage. */
const regions = [
{ code: "iad", ms: 18 }, { code: "ord", ms: 22 }, { code: "sfo", ms: 31 }, { code: "gru", ms: 64 },
{ code: "lhr", ms: 24 }, { code: "fra", ms: 21 }, { code: "arn", ms: 29 }, { code: "jnb", ms: 88 },
{ code: "bom", ms: 47 }, { code: "sin", ms: 39 }, { code: "nrt", ms: 35 }, { code: "syd", ms: 52 },
];
const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const TYPE_MS = 16;
const DONE_AT = 2950;
const END = 3050;
const HOLD = 4200;
const TICK = 50;
function useClock(playing: boolean, run: number, still: boolean | null) {
const [now, setNow] = useState(END);
const t = useRef(END);
useEffect(() => {
if (still) return;
t.current = 0;
setNow(0);
}, [run, still]);
useEffect(() => {
if (!playing || still) return;
const id = window.setInterval(() => {
if (document.hidden) return;
t.current += TICK;
if (t.current > END + HOLD) t.current = 0;
setNow(Math.min(t.current, END));
}, TICK);
return () => window.clearInterval(id);
}, [playing, still]);
return now;
}
/** A bar made of block characters, `size` cells wide. */
const bar = (p: number, size: number) => "█".repeat(Math.round(p * size)) + "░".repeat(size - Math.round(p * size));
export default function DeployLogTerminalWithProgressBarsAndSuccessBanner() {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { amount: 0.2 });
const [run, setRun] = useState(0);
const now = useClock(inView, run, reduce);
// Entrance animations only play once the page is live, so the server-rendered HTML shows the finished state.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const enter = mounted && !reduce;
const typed = Math.min(COMMAND.length, Math.floor(now / TYPE_MS));
const done = now >= DONE_AT;
const spin = SPIN[Math.floor(now / 80) % SPIN.length];
const last = stages[stages.length - 1];
const rollout = Math.min(1, Math.max(0, (now - last.from) / (last.to - last.from)));
const live = Math.floor(rollout * regions.length);
const elapsed = (Math.min(1, now / DONE_AT) * parseFloat(project.total)).toFixed(1);
const glow = "[text-shadow:0_0_10px_rgba(251,191,36,0.35)]";
return (
<section className="relative overflow-hidden bg-[#0c0a06] px-4 py-16 sm:px-6 sm:py-20">
<style>{`
@keyframes deploy-log-terminal-caret { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
@keyframes deploy-log-terminal-pulse { 0%, 100% { opacity: 0.45; } 50% { opacity: 1; } }
.deploy-log-terminal-caret { animation: deploy-log-terminal-caret 1s step-end infinite; }
.deploy-log-terminal-pulse { animation: deploy-log-terminal-pulse 0.5s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .deploy-log-terminal-caret, .deploy-log-terminal-pulse { animation: none; } }
`}</style>
<div aria-hidden="true" className={`pointer-events-none absolute left-1/2 top-1/2 h-[520px] w-[900px] -translate-x-1/2 -translate-y-1/3 rounded-full blur-[130px] transition-colors duration-1000 ${done ? "bg-lime-500/15" : "bg-amber-500/15"}`} />
<div className="relative mx-auto max-w-5xl">
<div className="max-w-2xl">
<p className="font-mono text-sm text-amber-400">$ tern --help</p>
<h2 className="mt-3 text-4xl font-semibold tracking-tight text-amber-50 sm:text-5xl">Every deploy, line by line</h2>
<p className="mt-4 text-lg leading-relaxed text-amber-100/80">No spinner that says nothing. You see each stage, how far along it is and which regions are already serving the new build.</p>
</div>
<div ref={ref} className={`relative mt-10 overflow-hidden rounded-xl bg-[#14100a] font-mono shadow-2xl shadow-black ring-1 transition-shadow duration-700 ${done ? "ring-lime-300/40" : "ring-amber-400/25"}`}>
<div className="flex items-center gap-3 border-b border-amber-400/15 py-1 pl-4 pr-1.5">
<span className={`h-2 w-2 rounded-full transition-colors duration-500 ${done ? "bg-lime-300" : "deploy-log-terminal-pulse bg-amber-400"}`} aria-hidden="true" />
<p className="min-w-0 flex-1 truncate text-xs text-amber-200/80">{project.name} · production</p>
<p className="text-xs tabular-nums text-amber-200/80" aria-hidden="true">{elapsed}s</p>
<button type="button" onClick={() => setRun((r) => r + 1)} className="h-10 rounded-md px-3 text-xs font-semibold text-amber-200 transition-colors hover:bg-amber-400/10 hover:text-amber-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-300">
Deploy again
</button>
</div>
<div className="grid lg:grid-cols-[1.7fr_1fr]">
{/* Log */}
<div className={`min-h-[448px] p-4 text-[12px] leading-6 text-amber-300 sm:p-5 sm:text-[13px] ${glow}`} role="log" aria-label="Deploy log">
<p className="text-amber-100">
<span className="select-none text-amber-500">$ </span>
{COMMAND.slice(0, typed)}
</p>
{now >= 300 && (
<p className="truncate text-amber-200/70">
{" "}branch {project.branch} · commit {project.commit}
</p>
)}
<div className="mt-2">
{stages.map((s) => {
if (now < s.from) return null;
const p = Math.min(1, (now - s.from) / (s.to - s.from));
const ok = p >= 1;
return (
<motion.div key={s.label} initial={enter ? { opacity: 0, y: 6 } : false} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2 }}>
<p className="flex gap-2 whitespace-pre">
<span className={`inline-block w-[1ch] shrink-0 ${ok ? "text-lime-300" : "text-amber-200"}`} aria-hidden="true">{ok ? "✔" : spin}</span>
<span className={`min-w-0 flex-1 truncate sm:w-48 sm:flex-none ${ok ? "text-amber-100" : "text-amber-200"}`}>{s.label}</span>
<span className={`hidden sm:inline ${ok ? "text-amber-600/70 [text-shadow:none]" : ""}`} aria-hidden="true">{bar(p, 30)}</span>
<span className={`sm:hidden ${ok ? "text-amber-600/70 [text-shadow:none]" : ""}`} aria-hidden="true">{bar(p, 8)}</span>
<span className={`w-[4ch] shrink-0 text-right tabular-nums sm:ml-auto ${ok ? "text-lime-300" : ""}`}>{ok ? s.took : `${Math.round(p * 100)}%`}</span>
</p>
{ok && (
<motion.p initial={enter ? { opacity: 0 } : false} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} className="truncate text-amber-200/70">
{" └ "}{s.note}
</motion.p>
)}
</motion.div>
);
})}
</div>
{done && (
<motion.div initial={enter ? { opacity: 0, scale: 0.96, y: 8 } : false} animate={{ opacity: 1, scale: 1, y: 0 }} transition={{ type: "spring", stiffness: 380, damping: 22 }} className="mt-3 origin-left">
<p className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="bg-lime-300 px-2 font-bold text-black [text-shadow:none]">✔ DEPLOYED</span>
<a href="#" className="rounded-sm text-lime-200 underline decoration-lime-300/50 underline-offset-4 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300">{project.url}</a>
</p>
<p className="text-amber-200/70">{" "}{project.total} total · 42 routes · 0 downtime</p>
<p className="text-amber-100">
<span className="select-none text-amber-500">$ </span>
<span className="deploy-log-terminal-caret inline-block h-[1.1em] w-2 translate-y-[0.2em] bg-amber-300" aria-hidden="true" />
</p>
</motion.div>
)}
</div>
{/* Regions */}
<div className="border-t border-amber-400/15 p-4 sm:p-5 lg:border-l lg:border-t-0">
<div className="flex items-baseline justify-between">
<p className="text-[11px] font-semibold uppercase tracking-widest text-amber-200/80">Edge regions</p>
<p className={`text-sm tabular-nums transition-colors duration-300 ${live === regions.length ? "text-lime-300" : "text-amber-300"}`}>
{String(live).padStart(2, "0")}/{regions.length}
</p>
</div>
<ul className="mt-3 grid grid-cols-4 gap-1.5 lg:grid-cols-3">
{regions.map((r, i) => {
const state = i < live ? "live" : i === live && rollout > 0 && rollout < 1 ? "sync" : "idle";
return (
<li key={r.code} className={`rounded-md px-2 py-2 ring-1 transition-colors duration-300 ${state === "live" ? "bg-lime-300/10 ring-lime-300/40" : state === "sync" ? "deploy-log-terminal-pulse bg-amber-400/15 ring-amber-300/60" : "bg-transparent ring-amber-400/15"}`}>
<span className={`block text-xs font-semibold uppercase ${state === "live" ? "text-lime-200" : state === "sync" ? "text-amber-200" : "text-amber-200/60"}`}>{r.code}</span>
<span className={`block text-[10px] tabular-nums ${state === "live" ? "text-lime-200/80" : "text-amber-200/60"}`}>{state === "live" ? `${r.ms} ms` : state === "sync" ? "sync" : "wait"}</span>
</li>
);
})}
</ul>
<div className="mt-5 border-t border-amber-400/15 pt-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-amber-200/80">Traffic on new build</p>
<p className={`mt-1 text-5xl font-bold tabular-nums tracking-tight transition-colors duration-500 ${rollout >= 1 ? "text-lime-300 [text-shadow:0_0_24px_rgba(190,242,100,0.45)]" : "text-amber-300 [text-shadow:0_0_24px_rgba(251,191,36,0.45)]"}`}>
{Math.round(rollout * 100)}<span className="text-2xl">%</span>
</p>
<div className="mt-3 flex gap-[3px]" aria-hidden="true">
{Array.from({ length: 24 }, (_, i) => (
<span key={i} className={`h-2.5 flex-1 rounded-[1px] transition-colors duration-200 ${i < Math.round(rollout * 24) ? (rollout >= 1 ? "bg-lime-300" : "bg-amber-300") : "bg-amber-400/15"}`} />
))}
</div>
</div>
<p className="mt-4 text-[11px] leading-5 text-amber-200/70">{done ? "Old build drained. Rollback stays one command away for 30 days." : "Traffic moves region by region. If a health check fails, the rollout stops."}</p>
</div>
</div>
{/* Scanlines */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(0deg,rgba(0,0,0,0.16)_0px,rgba(0,0,0,0.16)_1px,transparent_1px,transparent_3px)]" />
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/deploy-log-terminal-with-progress-bars-and-success-banner.json
They are text. `bar(p, size)` repeats a full block character for the finished share and a light shade character for the rest, so the bar sits on the same baseline as the log and scales with the font. The percentage beside it is the same number rounded.
It cycles through ten braille characters. The frame is picked from the clock, `Math.floor(now / 80)`, so every running line spins in step and there is no separate timer to clean up.
A typed demo prints finished lines. This one shows work in progress: each stage has a bar that fills, a percentage, and a second panel that follows the rollout region by region. It is closer to a real deploy view.
Yes. Each row only needs a progress value from 0 to 1. Replace the clock maths with the progress from your build events and render the same row markup. Nothing in the demo calls a service.
Block and box characters are in all common monospace fonts. The braille spinner falls back to a symbol font on a few systems, so it sits in a one character wide box to keep the columns aligned.
Related: Animated Terminal Window with Typed Commands, Multi-Step Loading Sequence with Drawn Checkmarks, Code Editor Window with AI Diff and Passing Tests.
More app and game ui or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.