A split section for a developer tool. On the right is a dark terminal window with traffic-light dots, a path in the title bar and a replay button. A command types itself after the prompt, then the output lines drop in one at a time with green ticks, a link line and a final summary, and a second command starts. After a pause the session plays again. On the left are a heading, a sentence, two buttons and three figures. On phones the terminal moves below the text and long lines wrap.
// Adapted from Magic UI (https://magicui.design/docs/components/terminal)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";
type Line =
| { kind: "type"; text: string }
| { kind: "out"; text: string; tone?: "ok" | "info" | "muted" | "done" };
/** The session, top to bottom. "type" lines are typed out, "out" lines appear one after another. */
const lines: Line[] = [
{ kind: "type", text: "npx harbor deploy --prod" },
{ kind: "out", text: "Harbor CLI 4.2.0", tone: "muted" },
{ kind: "out", text: "Linked to northwind/storefront", tone: "ok" },
{ kind: "out", text: "Installed 412 packages in 3.1s", tone: "ok" },
{ kind: "out", text: "Type check passed, 0 errors", tone: "ok" },
{ kind: "out", text: "Built 38 routes, 214 kB first load", tone: "ok" },
{ kind: "out", text: "Uploaded to 12 regions", tone: "ok" },
{ kind: "out", text: "Live at storefront.northwind.test", tone: "info" },
{ kind: "out", text: "Done in 9.4s", tone: "done" },
{ kind: "type", text: "harbor logs --follow" },
];
/** Milliseconds per typed character, and the pause between output lines. */
const TYPE_MS = 34;
const LINE_MS = 260;
const REPLAY_MS = 4200;
const tones = {
ok: "text-zinc-200",
info: "text-sky-300",
muted: "text-zinc-500",
done: "font-semibold text-emerald-300",
};
const facts = [
{ value: "9.4s", label: "median deploy" },
{ value: "12", label: "regions by default" },
{ value: "0", label: "config files" },
];
export default function AnimatedTerminalWindowWithTypedCommands() {
const reduce = useReducedMotion();
const box = useRef<HTMLDivElement>(null);
const inView = useInView(box, { amount: 0.3 });
// `step` is the line being played; `chars` is how much of a typed line is showing.
const [step, setStep] = useState(0);
const [chars, setChars] = useState(0);
const [run, setRun] = useState(0);
const finished = step >= lines.length;
useEffect(() => {
if (reduce) {
setStep(lines.length);
return;
}
if (!inView) return;
if (finished) {
const id = window.setTimeout(() => { setStep(0); setChars(0); setRun((r) => r + 1); }, REPLAY_MS);
return () => window.clearTimeout(id);
}
const line = lines[step];
if (line.kind === "type" && chars < line.text.length) {
const id = window.setTimeout(() => setChars((c) => c + 1), chars === 0 ? 420 : TYPE_MS);
return () => window.clearTimeout(id);
}
const id = window.setTimeout(() => { setStep((s) => s + 1); setChars(0); }, line.kind === "type" ? 380 : LINE_MS);
return () => window.clearTimeout(id);
}, [reduce, inView, finished, step, chars]);
const replay = () => { setStep(0); setChars(0); setRun((r) => r + 1); };
return (
<section className="bg-zinc-100 px-4 py-16 sm:px-6 sm:py-20">
<style>{`
@keyframes animated-terminal-window-with-typed-commands-blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
.animated-terminal-window-with-typed-commands-caret { animation: animated-terminal-window-with-typed-commands-blink 1s step-end infinite; }
@media (prefers-reduced-motion: reduce) { .animated-terminal-window-with-typed-commands-caret { animation: none; } }
`}</style>
<div className="mx-auto grid max-w-6xl items-center gap-12 lg:grid-cols-[1fr_1.15fr]">
<div>
<p className="font-mono text-sm font-medium text-emerald-700">$ harbor --help</p>
<h2 className="mt-4 text-4xl font-semibold tracking-tight text-zinc-900 sm:text-5xl">One command from laptop to live</h2>
<p className="mt-4 max-w-md text-lg leading-relaxed text-zinc-600">Harbor reads your project, builds what changed and puts it on the edge. No dashboard to click through and nothing to configure first.</p>
<div className="mt-8 flex flex-wrap gap-3">
<a href="#" className="inline-flex h-12 items-center rounded-lg bg-zinc-900 px-6 text-sm font-semibold text-white transition-colors hover:bg-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100">
Install the CLI
</a>
<a href="#" className="inline-flex h-12 items-center rounded-lg border border-zinc-300 bg-white px-6 text-sm font-semibold text-zinc-900 transition-colors hover:border-zinc-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600">
Read the docs
</a>
</div>
<dl className="mt-10 grid max-w-md grid-cols-3 gap-6 border-t border-zinc-300 pt-6">
{facts.map((f) => (
<div key={f.label} className="flex flex-col-reverse">
<dt className="text-sm text-zinc-600">{f.label}</dt>
<dd className="font-mono text-2xl font-semibold text-zinc-900">{f.value}</dd>
</div>
))}
</dl>
</div>
<div ref={box} className="relative">
<div aria-hidden="true" className="absolute -inset-4 rounded-[32px] bg-gradient-to-br from-emerald-300/60 via-teal-200/40 to-sky-300/60 blur-2xl" />
<div className="relative overflow-hidden rounded-2xl bg-zinc-950 shadow-2xl shadow-zinc-900/30 ring-1 ring-white/10">
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-3">
<span className="h-3 w-3 rounded-full bg-red-400" aria-hidden="true" />
<span className="h-3 w-3 rounded-full bg-amber-400" aria-hidden="true" />
<span className="h-3 w-3 rounded-full bg-emerald-400" aria-hidden="true" />
<span className="ml-3 flex-1 truncate font-mono text-xs text-zinc-400">~/code/storefront</span>
<button type="button" onClick={replay} className="-my-2 h-10 rounded-md px-3 font-mono text-xs text-zinc-300 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400">
Replay
</button>
</div>
<pre className="h-[344px] overflow-hidden p-5 font-mono text-[13px] leading-7 sm:text-sm sm:leading-7" aria-label="Terminal session showing a deploy">
<code key={run} className="block">
{lines.map((line, i) => {
if (i > step) return null;
if (line.kind === "type") {
const active = i === step;
return (
<span key={i} className="block whitespace-pre-wrap text-white">
<span className="select-none text-emerald-400">$ </span>
{active ? line.text.slice(0, chars) : line.text}
{active && <span className="animated-terminal-window-with-typed-commands-caret ml-0.5 inline-block h-[1.1em] w-2 translate-y-[0.2em] bg-emerald-400" aria-hidden="true" />}
</span>
);
}
const tone = line.tone ?? "ok";
return (
<motion.span
key={i}
initial={reduce ? false : { opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: "easeOut" }}
className={`block whitespace-pre-wrap ${tones[tone]}`}
>
{tone === "ok" && <span className="select-none text-emerald-400">{"✓ "}</span>}
{tone === "info" && <span className="select-none">{"→ "}</span>}
{line.text}
</motion.span>
);
})}
{finished && (
<span className="block text-white">
<span className="select-none text-emerald-400">$ </span>
<span className="animated-terminal-window-with-typed-commands-caret ml-0.5 inline-block h-[1.1em] w-2 translate-y-[0.2em] bg-emerald-400" aria-hidden="true" />
</span>
)}
</code>
</pre>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/animated-terminal-window-with-typed-commands.json
Two pieces of state drive it: the index of the line being played and the number of characters typed on that line. One effect looks at the current line. If it is a typed line with characters left, it schedules the next character. Otherwise it schedules the move to the next line. When the index passes the end, a longer timer resets both.
No. It uses an in-view check on the window and only advances while at least a third of it is visible, so readers lower down the page still see the start.
Remove the block that begins with `if (finished)` inside the effect. The session then stays on its last frame and the replay button still works.
Yes. Add more `type` lines anywhere in the array. Each one is typed in turn and the output lines after it follow. Raise the height of the `pre` to fit.
Most terminals use a block cursor, so it reads as a terminal at a glance. Change `w-2` to `w-px` on the caret span for a thin bar.
Adapted from Terminal in Magic UI, used under its open-source licence. Licences and credits
Related: Typewriter Headline with Cycling Phrases, Multi-Step Loading Sequence with Drawn Checkmarks, Split Hero with Self-Playing Chat Demo.
More text effects 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.