Animated Terminal Window with Typed Commands

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.

Code

// 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

When to use it

How to customize it

Accessibility

Questions

How does the sequence work?

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.

Does it start before it is on screen?

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.

How do I stop it looping?

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.

Can I show more than one command?

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.

Why is the caret a block and not a line?

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.

More in Text Effects

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.

Product

Who It's For

Features

Resources

Company

View Sitemap