<!--
  canonical-url: https://www.avonture.be/blog/docusaurus-terminal-typewriter
  published:     2026-06-22
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Bring Your CLI Tutorials to Life with a Typewriter Terminal

> Add a typewriter animation to your Docusaurus Terminal component — command lines typed character-by-character, output lines appearing in sequence, with a blinking cursor and a click-to-skip escape hatch. Three new props, zero breaking changes.

---
![Bring Your CLI Tutorials to Life with a Typewriter Terminal](/img/v2/typewriter_terminal.webp)

**TL;DR**

Static code blocks explain what to run. An animated terminal shows it *happening*. This article extends the existing `Terminal` component with a `typewriter` prop: command lines (those starting with `$` or `#`) are typed character-by-character with a blinking cursor; output lines appear whole after a short pause. Three new props control speed and pacing. The change is fully backward-compatible — every existing `<Terminal>` block stays identical unless you opt in. Implementation is pure React state + `setTimeout`, no library dependency.

CLI tutorials are dense. A reader follows along, copy-pasting commands, hoping the output they see matches what you wrote. But when the article just shows a static block of text, the sequence of events — the command, the pause, the response — collapses into a single wall of monospace.

A typewriter animation restores that sequence. The command appears letter by letter, as if someone is actually typing it. Then the output follows. The reader's eye is guided through the interaction naturally, without any extra explanation.

**Quick Jump:** [The new typewriter prop](#the-new-typewriter-prop) | [Create the component](#create-the-component)

If you're a regular reader of my blog, you know that I've developed a component called `Terminal` that lets me list a series of commands I've run in a Linux-style console—and it looks pretty realistic. It's the console counterpart of [the component I use for showing code snippets](/blog/docusaurus-snippets). Here's an example:

```bash
$ docker compose up -d
[+] Running 3/3
✔ Network myapp_default   Created
✔ Container myapp-db-1    Started
✔ Container myapp-web-1   Started
```

However, it's a little lacking in "life"; let's see how we can make it look pretty cool with a typewriter-style animation.

The Terminal output here above has been obtained using this block in my Markdown content:

```jsx
<Terminal title="user@machine: ~/project">
$ docker compose up -d

[+] Running 3/3
 ✔ Network myapp_default   Created
 ✔ Container myapp-db-1    Started
 ✔ Container myapp-web-1   Started

</Terminal>
```

Docusaurus will then do the rendering during the preview of this post or during the HTML generation.

## The new `typewriter` prop

Add `typewriter` to opt into the animation:

```jsx
<Terminal typewriter>
$ docker compose up -d

[+] Running 3/3
 ✔ Network myapp_default   Created
 ✔ Container myapp-db-1    Started
 ✔ Container myapp-web-1   Started

</Terminal>
```

Here is what happens, frame by frame:

1. `$` appears, then `d`, then `o`, then `c`… the command types itself out character by character
2. A blinking green `▋` cursor tracks the current position
3. Once the command is fully typed, a brief pause lets it settle
4. `[+] Running 3/3` appears as a complete line
5. Each output line follows in sequence
6. The cursor disappears when the animation ends

> **Skip the animation:**
>
> Click anywhere on the terminal to skip to the end. Useful for readers who have already seen the animation and just want to copy the commands.

```bash
$ docker compose up -d
[+] Running 3/3
✔ Network myapp_default   Created
✔ Container myapp-db-1    Started
✔ Container myapp-web-1   Started
```

Another example:

```bash
$ npm install
added 347 packages, and audited 348 packages in 12s
42 packages are looking for funding
run "npm fund for details
found 0 vulnerabilities
```

```bash
$ npm run build
✓ frontend@1.0.0 build
✓ vite build
vite v6.2.1 building for production...
✓ 124 modules transformed.
dist/index.html                  0.58 kB
dist/assets/index-8af3d2.js    215.43 kB
✓ built in 2.14s
$ npm run preview
✓ frontend@1.0.0 preview
✓ vite preview
➜  Local:   http://localhost:4173/
➜  Network: http://192.168.1.42:4173/
```

```bash
$ git status
On branch feature/authentication
Changes not staged for commit:
modified: src/auth/login.ts
modified: src/auth/session.ts
$ git add .
$ git commit -m "feat(auth): add session refresh"
[feature/authentication 7c1f2ab] feat(auth): add session refresh
2 files changed, 48 insertions(+), 12 deletions(-)
$ git push origin feature/authentication
Enumerating objects: 12, done.
Counting objects: 100% (12/12), done.
Writing objects: 100% (8/8), 1.24 KiB | 1.24 MiB/s, done.
Total 8 (delta 4), reused 0 (delta 0)
To github.com:company/project.git
e1a9c2f..7c1f2ab  feature/authentication -> feature/authentication
```

```bash
$ docker compose up -d
[+] Running 4/4
✔ Network api_default          Created
✔ Container api-db-1           Started
✔ Container api-redis-1        Started
✔ Container api-backend-1      Started
$ docker compose ps
NAME              STATUS         PORTS
api-db-1          Up 3 seconds   5432/tcp
api-redis-1       Up 3 seconds   6379/tcp
api-backend-1     Up 2 seconds   0.0.0.0:8080->8080/tcp
$ docker compose logs backend --tail=10
backend-1 | Connecting to PostgreSQL...
backend-1 | Database connection established
backend-1 | Redis connection established
backend-1 | HTTP server listening on :8080
```

```bash
$ apt update
Hit:1 http://archive.ubuntu.com/ubuntu noble InRelease
Get:2 http://security.ubuntu.com/ubuntu noble-security InRelease [126 kB]
Fetched 126 kB in 1s
$ apt upgrade -y
Reading package lists...
Building dependency tree...
Calculating upgrade...
12 upgraded, 0 newly installed, 0 to remove.
$ systemctl restart nginx
$ systemctl status nginx
● nginx.service - nginx web server
Loaded: loaded
Active: active (running)
$ df -h
Filesystem      Size  Used Avail Use%
/dev/sda1        80G   41G   36G  54%
```

## Animation starts only when visible

A terminal buried at the bottom of a long article would finish its animation before the reader ever reached it. The component solves this with the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API): the typewriter effect starts only when at least 10 % of the terminal enters the viewport. Scroll past it and the animation greets you exactly when you arrive.

> **Tip:**
>
> Want to check this? Simply reload this page (`CTRL`+`F5`) and just press End to jump to the conclusion of this post. Then, scroll up.  Animations will start. Just because the Terminal component is now in the visible part of the page.

## Speed scales automatically with line count

Two additional props let you fine-tune pacing, but you rarely need them — the component auto-scales based on the number of lines:

| Lines | `typewriterSpeed` | `typewriterLineDelay` | Approx. total duration |
| ----- | ----------------- | --------------------- | ---------------------- |
| ≤ 5   | 40 ms/char        | 400 ms/line           | \~2 s                  |
| 6–10  | 25 ms/char        | 200 ms/line           | \~2.5 s                |
| 11–20 | 20 ms/char        | 150 ms/line           | \~3.5 s                |
| > 20  | 12 ms/char        | 100 ms/line           | \~3.5 s                |

A 30-line session completes in roughly the same time as a 3-line one. The animation stays engaging without becoming a waiting game.

Override only when you need a specific feel — a slow, deliberate introduction to a new tool:

```jsx
<Terminal typewriter typewriterSpeed={60} typewriterLineDelay={600}>
$ fzf --version

0.54.3 (brew)
</Terminal>
```

## How the lines are classified

The animation distinguishes two types of lines by looking at the first non-whitespace characters:

* **Command lines** — start with `$` or `#`. Typed character-by-character at `typewriterSpeed` ms/char.
* **Output lines** — everything else. Revealed as a complete line after `typewriterLineDelay` ms.
* **Blank lines** — appear after an 80 ms pause, preserving the visual breathing room of the original content.

This means you don't need to annotate your content in any special way. Write the terminal session naturally; the component infers the structure.

## Zero breaking changes

Every existing `<Terminal>` block in the codebase continues to render exactly as before. The `typewriter` prop defaults to `false`, so the static behavior is the default.

If you want to enable the animation everywhere in one step, a global search-and-replace on `<Terminal` → `<Terminal typewriter` across the `blog/` directory is all it takes:

```bash
grep -rl "<Terminal" blog/ | xargs sed -i 's/<Terminal\b/<Terminal typewriter/g'
```

## Implementation sketch

The component holds three pieces of state during animation:

```plaintext
revealedLines  — lines fully shown so far (string[])
lineIdx        — which line is currently being animated (number)
charIdx        — how many characters of that line are visible (number)
```

A single `useEffect` drives the animation tick. On each render it decides which timeout to schedule next:

* If the current line is a command and `charIdx < line.length` → schedule `setCharIdx(c => c + 1)` after `typewriterSpeed` ms
* Otherwise (output line, or command fully typed) → schedule the move to the next line after `typewriterLineDelay` ms

When `lineIdx` reaches the end of the lines array, `animDone` is set to `true` and the component switches back to rendering the original `{children}` React node — so any syntax highlighting or rich markup in the children is preserved after the animation completes.

The blinking cursor is a single CSS animation:

```css
@keyframes blink {
  0%, 100% { opacity: 1; }
  50%       { opacity: 0; }
}

.cursor {
  display: inline-block;
  color: #00ff00;
  animation: blink 1s step-end infinite;
}
```

No external library. No canvas. Just state and timers.

## Create the component

You'll need to create three new files to your own Docusaurus site. The fourth file is optional and this is the component's documentation:

**Project: /your\_docusaurus\_site**

**src/components/Terminal/index.tsx**

```typescript
import {
  Fragment,
  useState,
  useEffect,
  useCallback,
  useMemo,
  useRef,
  type JSX,
  type ReactElement,
  type ReactNode,
  type SVGProps,
} from "react";
import { useMDXComponents } from "@mdx-js/react";
import clsx from "clsx";
import { useVarResolver } from "@site/src/components/Vars/store";
import {
  substituteChildren,
  substitutePlainText,
} from "@site/src/components/Vars/substitute";
import VarToken from "@site/src/components/Vars/VarToken";
import Icon from "./icon.svg";
import styles from "./styles.module.css";
import { translate } from "@docusaurus/Translate";

// headingPrefixMap is built inside the component (via useMDXComponents) and
// passed here so both copy and animation recover the "# " prefix that MDX strips.
// `separator` joins *sibling* array items — "\n" by default, because a bare
// array of Terminal children is one source line per item (see the heading
// comment below). `substituteChildren` (TODO 0088) wraps the fragments of a
// single line it split — e.g. "--name " / a VarToken / " -p " — in a
// Fragment, and that one case needs "" instead: switched right below, when
// recursing into a Fragment's own children, so a substituted value never
// gets a spurious line break glued to it (it did — see git history).
type HeadingPrefixMap = Map<unknown, string>;

function getCopyText(
  children: ReactNode,
  headingPrefixMap: HeadingPrefixMap,
  separator = "\n",
): string {
  let text = "";
  if (Array.isArray(children)) {
    children.forEach((child, index) => {
      text += getCopyText(child, headingPrefixMap);
      if (index < children.length - 1) {
        text += separator;
      }
    });
  } else if (typeof children === "string") {
    text += children;
  } else if (typeof children === "object" && children !== null) {
    const el = children as ReactElement;
    const elProps = el.props as { children?: ReactNode };
    const prefix = headingPrefixMap?.get(el.type) ?? "";
    if (elProps.children) {
      const childSeparator = el.type === Fragment ? "" : "\n";
      text += prefix + getCopyText(elProps.children, headingPrefixMap, childSeparator);
    }
  }
  return text;
}

// Auto-scales speed based on line count so long terminals don't drag on.
// Explicit props always take priority over these defaults.
function autoSpeed(lineCount: number) {
  if (lineCount <= 5) return { speed: 40, delay: 400 };
  if (lineCount <= 10) return { speed: 25, delay: 200 };
  if (lineCount <= 20) return { speed: 20, delay: 150 };
  return { speed: 12, delay: 100 };
}

const CopyIcon = (props: SVGProps<SVGSVGElement>) => (
  <svg viewBox="0 0 24 24" {...props}>
    <path
      fill="currentColor"
      d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"
    />
  </svg>
);

const CheckIcon = (props: SVGProps<SVGSVGElement>) => (
  <svg viewBox="0 0 24 24" {...props}>
    <path
      fill="currentColor"
      d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"
    />
  </svg>
);

interface Props {
  children: ReactNode;
  title?: string;
  wrap?: boolean;
  /** Enables typewriter animation. Command lines ($/#) typed char-by-char; output lines appear whole. Click to skip. */
  typewriter?: boolean;
  /** ms per character on command lines. Omit to auto-scale based on line count. */
  typewriterSpeed?: number;
  /** ms before each output line appears. Omit to auto-scale based on line count. */
  typewriterLineDelay?: number;
  /** When true, the copy button copies only command lines ($ / #), with the prompt prefix stripped, so the result is directly pasteable. */
  copyCommandOnly?: boolean;
}

export default function Terminal({
  children,
  title,
  wrap = true,
  typewriter = false,
  typewriterSpeed,
  typewriterLineDelay,
  copyCommandOnly = false,
}: Props): JSX.Element {
  const resolve = useVarResolver();
  const displayTitle = substitutePlainText(
    title || "user@machine: ~/yourproject",
    resolve,
  );
  const containerRef = useRef<HTMLDivElement | null>(null);
  const [copied, setCopied] = useState(false);

  // Docusaurus registers h1–h6 as anonymous arrow functions in the MDX components
  // map, so children.type is a function reference, not the string "h1". Build a
  // Map keyed by those exact references so getCopyText can recover the "# " prefix.
  const mdxComponents = useMDXComponents();
  const headingPrefixMap = useMemo<HeadingPrefixMap>(() => {
    const map = new Map<unknown, string>();
    ["h1", "h2", "h3", "h4", "h5", "h6"].forEach((tag, i) => {
      const prefix = "#".repeat(i + 1) + " ";
      if (mdxComponents[tag]) map.set(mdxComponents[tag], prefix);
      map.set(tag, prefix); // fallback for native HTML headings
    });
    return map;
  }, [mdxComponents]);

  // Resolve any `default` marker (TODO 0088) against the reader's
  // current values before anything else touches `children` — copy, the
  // typewriter animation, and the plain render all read the resolved tree
  // from here on, so a substitution can never accidentally skip one of them.
  const resolvedChildren = useMemo(
    () =>
      substituteChildren(children, resolve, (name, value, key) => (
        <VarToken key={key}>{value}</VarToken>
      )),
    [children, resolve],
  );

  // Extract lines once from children text content. Deliberately captured only
  // once per typewriter run, not kept live: if the reader edits a value while
  // the animation is still typing, the in-flight animation finishes with the
  // text it started with — resolvedChildren (used once animDone, and by copy)
  // always reflects the latest edit.
  const animLines = useMemo(
    () => (typewriter ? getCopyText(resolvedChildren, headingPrefixMap).split("\n") : []),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [typewriter],
  );

  // Effective speed: explicit prop wins; otherwise auto-scale to line count
  const { speed: effectiveSpeed, delay: effectiveDelay } = useMemo(() => {
    const auto = autoSpeed(animLines.length);
    return {
      speed: typewriterSpeed ?? auto.speed,
      delay: typewriterLineDelay ?? auto.delay,
    };
  }, [animLines.length, typewriterSpeed, typewriterLineDelay]);

  // Start animation only when the terminal scrolls into view
  const [hasBeenVisible, setHasBeenVisible] = useState(false);

  useEffect(() => {
    if (!typewriter) return;
    const el = containerRef.current;
    if (!el || typeof IntersectionObserver === "undefined") {
      // Fallback for environments without IntersectionObserver (SSR, old browsers)
      setHasBeenVisible(true);
      return;
    }
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setHasBeenVisible(true);
          observer.disconnect();
        }
      },
      { threshold: 0.1 },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [typewriter]);

  const [revealedLines, setRevealedLines] = useState<string[]>([]);
  const [lineIdx, setLineIdx] = useState(0);
  const [charIdx, setCharIdx] = useState(0);
  const [animDone, setAnimDone] = useState(false);

  // Animation tick — only runs once the component is visible
  useEffect(() => {
    if (!typewriter || !hasBeenVisible || animDone) return;

    if (lineIdx >= animLines.length) {
      // eslint-disable-next-line react-hooks/set-state-in-effect -- terminal step of a setTimeout-driven typewriter animation, not derivable at render time
      setAnimDone(true);
      return;
    }

    const line = animLines[lineIdx];
    const isCommand = /^\s*[$#]/.test(line);

    if (isCommand && charIdx < line.length) {
      const t = setTimeout(() => setCharIdx((c) => c + 1), effectiveSpeed);
      return () => clearTimeout(t);
    }

    // Line complete — pause then advance
    const pause = line.trim() === "" ? 80 : effectiveDelay;
    const t = setTimeout(() => {
      setRevealedLines((prev) => [...prev, line]);
      setLineIdx((l) => l + 1);
      setCharIdx(0);
    }, pause);
    return () => clearTimeout(t);
  }, [
    typewriter,
    hasBeenVisible,
    animDone,
    lineIdx,
    charIdx,
    animLines,
    effectiveSpeed,
    effectiveDelay,
  ]);

  const skipAnimation = useCallback(() => {
    if (!typewriter || animDone) return;
    setRevealedLines(animLines);
    setLineIdx(animLines.length);
    setAnimDone(true);
  }, [typewriter, animDone, animLines]);

  const handleCopy = useCallback(async () => {
    let textToCopy = getCopyText(resolvedChildren, headingPrefixMap);
    if (copyCommandOnly) {
      textToCopy = textToCopy
        .split("\n")
        .filter((line) => /^\s*[$#]/.test(line))
        .map((line) => line.replace(/^\s*[$#] ?/, ""))
        .join("\n");
    }
    try {
      await navigator.clipboard.writeText(textToCopy);
      setCopied(true);
    } catch (err) {
      console.error("Failed to copy text: ", err);
    }
  }, [resolvedChildren, headingPrefixMap, copyCommandOnly]);

  useEffect(() => {
    if (copied) {
      const timer = setTimeout(() => setCopied(false), 2000);
      return () => clearTimeout(timer);
    }
  }, [copied]);

  const animDisplay = useMemo(() => {
    if (!typewriter || animDone) return null;
    const partial = animLines[lineIdx]?.slice(0, charIdx) ?? "";
    return [...revealedLines, partial].join("\n");
  }, [typewriter, animDone, revealedLines, lineIdx, charIdx, animLines]);

  const isAnimating = typewriter && !animDone;

  return (
    <div
      ref={containerRef}
      className={styles.terminal}
      onClick={skipAnimation}
      style={isAnimating ? { cursor: "pointer" } : undefined}
      title={
        isAnimating
          ? translate({
              id: "terminal.skipAnimation",
              message: "Click to skip animation",
            })
          : undefined
      }
    >
      <div className={styles.terminal_header}>
        <div className={styles.terminal_left}>
          <Icon className={styles.terminal_icon} />
          <span className={styles.terminal_title}>{displayTitle}</span>
        </div>
        <div className={styles.terminal_controls}>
          <span className={clsx(styles.dot, styles.red)} />
          <span className={clsx(styles.dot, styles.yellow)} />
          <span className={clsx(styles.dot, styles.green)} />
        </div>
      </div>

      <pre
        className={clsx(styles.terminal_body, {
          [styles.no_wrap]: !wrap,
        })}
      >
        {isAnimating ? (
          <>
            {/*
             * Ghost: renders the real children invisibly so the <pre> immediately
             * occupies its final height. Prevents page reflow on every typed line.
             */}
            <span className={styles.terminal_ghost} aria-hidden="true">
              {resolvedChildren}
            </span>
            {/* Animated text overlaid on the ghost */}
            <span className={styles.terminal_overlay}>
              {animDisplay}
              <span className={styles.cursor} aria-hidden="true">
                ▋
              </span>
            </span>
          </>
        ) : (
          resolvedChildren
        )}
      </pre>

      <button
        type="button"
        aria-label={translate({
          id: "common.copyCode",
          message: "Copy code to clipboard",
        })}
        className={clsx(
          "clean-btn",
          "button--sm",
          "button--secondary",
          styles.copyButton,
          { [styles.copied]: copied },
        )}
        onClick={(e) => {
          e.stopPropagation();
          handleCopy();
        }}
      >
        {copied ? (
          <CheckIcon className={styles.copyButtonSvg} />
        ) : (
          <CopyIcon className={styles.copyButtonSvg} />
        )}
      </button>
    </div>
  );
}
```

**src/components/Terminal/icon.svg**

```svg
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Terminal Prompt">
  <rect x="0" y="0" width="64" height="64" rx="10" ry="10" fill="#322a20" />
  <text x="32" y="40" text-anchor="middle" font-family="JetBrains Mono, ui-monospace, monospace" font-size="32" fill="#93b764" dominant-baseline="middle">&gt;</text>
</svg>
```

**src/components/Terminal/styles.module.css**

```css
/*
 * Terminal — stays a real terminal: dark in both themes, macOS traffic lights,
 * prompt and cursor. That is deliberate and unchanged.
 *
 * What changed:
 *   - Three hardcoded "Fira Code" declarations and one bare `monospace` →
 *     var(--ifm-font-family-monospace). Changing the site's code face is now
 *     one line, not five.
 *   - #1e1e1e / #2d2d2d (neutral grey-black) → the warm --mk-term-* shell, so
 *     the terminal belongs to the same world as the rest of the page.
 *   - #00ff00 for the prompt and cursor → --mk-term-prompt, the savanna green.
 *     Pure green on near-black is the single harshest colour pair on the site
 *     and it appeared in every article.
 *   - The macOS dots keep their meaning but are desaturated to the palette.
 *   - The header title is an eyebrow, matching Snippet's toolbar exactly.
 *
 * Every colour below is a token: no stylelint-disable needed any more.
 */

.terminal {
  position: relative;
  margin: 1.5rem 0;
  overflow-x: auto;
  font-family: var(--ifm-font-family-monospace);
  color: var(--mk-code-fg);
  background: var(--mk-term-bg);
  border: 1px solid var(--mk-term-border);
  border-radius: var(--mk-radius-md);
  box-shadow: var(--mk-shadow-1);
}

.terminal_header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.5rem 0.875rem;
  background: var(--mk-term-head);
  border-bottom: 1px solid var(--mk-term-border);
  border-radius: var(--mk-radius-md) var(--mk-radius-md) 0 0;
}

.terminal_left {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  overflow: hidden;
  white-space: nowrap;
}

.terminal_icon {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  color: var(--mk-n-500);
}

.terminal_title {
  composes: mk-eyebrow from global;
  overflow: hidden;
  color: var(--mk-n-500);
  text-overflow: ellipsis;
}

.terminal_controls {
  display: flex;
  gap: 5px;
}

.dot {
  display: inline-block;
  width: 9px;
  height: 9px;
  border-radius: 50%;
}

.red {
  background: var(--mk-term-dot-red);
}

.yellow {
  background: var(--mk-term-dot-amber);
}

.green {
  background: var(--mk-term-dot-green);
}

.terminal_body {
  position: relative;
  margin: 0;
  padding: 0.875rem 1rem;
  font-family: var(--ifm-font-family-monospace);
  font-size: 0.85rem;
  line-height: 1.6;
  white-space: pre-wrap;
  word-break: break-all;
  background: var(--mk-term-bg);
}

.terminal_line {
  display: flex;
  gap: 0.5rem;
}

.first_line {
  margin-bottom: 0.5rem;
}

.prompt {
  flex-shrink: 0;
  color: var(--mk-term-prompt);
  user-select: none;
  pointer-events: none;
}

.terminal_line span {
  display: inline-block;
  min-width: 0;
}

.no_wrap {
  overflow-x: auto;
  white-space: pre;
  word-break: normal;
}

/* Copy button — unchanged behaviour (fades in on container hover), retokenised. */
.copyButton {
  position: absolute;
  top: 2.75rem;
  right: 0.5rem;
  z-index: 10;
  padding: 0.35rem;
  color: var(--mk-n-500);
  cursor: pointer;
  background: transparent;
  border: none;
  border-radius: var(--mk-radius-sm);
  opacity: 0;
  transition:
    opacity 0.2s ease-in-out,
    color 0.2s ease-in-out;
}

.copyButton:hover {
  color: var(--mk-code-fg);
  background: rgb(255, 255, 255, 0.08);
}

.copyButton:focus-visible {
  opacity: 1;
  outline: 2px solid var(--mk-term-prompt);
  outline-offset: 2px;
}

.terminal:hover .copyButton {
  opacity: 1;
}

.copyButtonSvg {
  width: 1rem;
  height: 1rem;
  fill: currentcolor;
}

.terminal a {
  color: var(--mk-term-prompt);
  text-decoration: underline;
  text-underline-offset: 2px;
}

.terminal_ghost {
  visibility: hidden;
  user-select: none;
  pointer-events: none;
}

.terminal_overlay {
  position: absolute;
  inset: 0;
  padding: inherit;
  font-family: inherit;
  color: var(--mk-code-fg);
  white-space: inherit;
  word-break: inherit;
}

@keyframes blink {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0;
  }
}

.cursor {
  display: inline-block;
  font-family: var(--ifm-font-family-monospace);
  color: var(--mk-term-prompt);
  animation: blink 1s step-end infinite;
}

@media (prefers-reduced-motion: reduce) {
  .cursor {
    animation: none;
  }
}

/* MDX turns "# foo" inside <Terminal> into <h1>; reset so it reads as a shell
 * comment rather than a chapter heading. */
.terminal_body :is(h1, h2, h3, h4, h5, h6) {
  margin: 0;
  padding: 0;
  font-size: inherit;
  font-weight: inherit;
  line-height: inherit;
  letter-spacing: inherit;
  color: var(--mk-term-comment);
  border: none;
}

.terminal_body h1::before {
  content: "# ";
}
.terminal_body h2::before {
  content: "## ";
}
.terminal_body h3::before {
  content: "### ";
}
.terminal_body h4::before {
  content: "#### ";
}
.terminal_body h5::before {
  content: "##### ";
}
.terminal_body h6::before {
  content: "###### ";
}

.terminal_body p {
  margin: 0;
  padding: 0;
}
```

**src/components/Terminal/readme.md**

````markdown
# 🖥️ Terminal Component

A stylized terminal emulator UI for Docusaurus pages. Ideal for showcasing CLI commands, installation steps, or shell output in a visually engaging format.

## ✨ Features

- 🧑‍💻 Linux-style header with icon and control dots
- 📝 Customizable terminal title
- 💻 Styled code block for terminal content
- 🎨 Theme-friendly layout using scoped CSS
- ⌨️ Optional typewriter animation — command lines typed char-by-char, output appears whole

## Example

Out-of-the-box, here is how the component will looks like:

![Example](sample.png)

## 📁 Location

This component lives at `src/components/Terminal/index.tsx`.

## 🚀 Usage

```jsx
import Terminal from "@site/src/components/Terminal";

<Terminal title="user@machine: ~/project">npm install npm run build</Terminal>;
```

If no title is provided, the default is: `christophe@home: ~`

### With Word Wrapping Disabled

```jsx
<Terminal wrap={false}>
  $ echo "This is a very long line that should not wrap unless explicitly allowed"
</Terminal>
```

## 🛠 Props

| Prop                  | Type              | Required | Default                       | Description                                                                                                                                                                                                                                               |
| --------------------- | ----------------- | -------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `children`            | `React.ReactNode` | ✅*      | —                             | Terminal content to display. Not needed when `source` is used (plugin injects it automatically).                                                                                                                                                          |
| `source`              | string            | ❌       | —                             | Path to an external `.txt` file relative to the article (e.g. `./files/terminal-1.txt`). The `remark-snippet-loader` plugin reads the file at build time and injects its content as children. Use this to keep long terminal outputs out of the MDX file. |
| `title`               | string            | ❌       | `user@machine: ~/yourproject` | Optional terminal title shown in the header                                                                                                                                                                                                               |
| `wrap`                | boolean           | ❌       | `true`                        | Enables word wrapping in the terminal body. Set to `false` to disable it.                                                                                                                                                                                 |
| `typewriter`          | boolean           | ❌       | `false`                       | Enables typewriter animation. Lines starting with `$` or `#` are typed char-by-char; output lines appear whole. Click the terminal to skip. Animation starts only when the terminal scrolls into view.                                                    |
| `typewriterSpeed`     | number            | ❌       | auto                          | ms per character on command lines. Omit to auto-scale: ≤5 lines→40, ≤10→25, ≤20→20, >20→12.                                                                                                                                                               |
| `typewriterLineDelay` | number            | ❌       | auto                          | ms before each output line appears. Omit to auto-scale: ≤5 lines→400, ≤10→200, ≤20→150, >20→100.                                                                                                                                                          |

\* `children` is required when `source` is not provided.

### Loading content from a file (`source`)

For long terminal outputs (typically > 5 lines), you can store the content in a separate `.txt` file and reference it with `source`. The `remark-snippet-loader` plugin injects the file content at build time — no runtime fetch involved.

```jsx
<Terminal typewriter source="./files/terminal-1.txt" />
```

The file `./files/terminal-1.txt` is co-located with the article (same `files/` subfolder used by `Snippet`). Its content is plain text, exactly as it would appear inline:

```text
$ docker compose up --detach
[+] Running 2/2
 ✔ Network demo_default  Created
 ✔ Container counter     Started
```

All other props (`typewriter`, `title`, `wrap`, etc.) work exactly the same.

### Typewriter mode example

```jsx
<Terminal title="user@machine: ~/project" typewriter>
  {`$ docker compose up -d
[+] Running 3/3
 ✔ Network myapp_default   Created
 ✔ Container myapp-db-1    Started
 ✔ Container myapp-web-1   Started`}
</Terminal>
```

Speed is auto-scaled from line count by default — no need to set `typewriterSpeed` or `typewriterLineDelay` manually. Override only when you want a specific pacing:

```jsx
<Terminal typewriter typewriterSpeed={60} typewriterLineDelay={600}>
  {`$ fzf --version
0.54.3`}
</Terminal>
```

## 💡 Command Detection

Lines starting with `$` or `>` are treated as commands and rendered with a prompt symbol (`>`). This helps visually distinguish commands from output.

## 🎨 Styling

This component uses scoped CSS via `styles.module.css`. Key classes include:

- `.terminal` — outer container
- `.terminal_header` — header bar
- `.terminal_left` — icon + title
- `.terminal_controls` — control dots
- `.terminal_body` — code block area
- `.terminal_line` — individual line
- `.prompt` — command prompt symbol
- `.no_wrap` — disables word wrapping
- `.dot.red`, `.dot.yellow`, `.dot.green` — control dot colors

Customize these styles to match your site's design system.

## ⚠️ Escaping $

When writing shell commands inside JSX, be mindful that the `$` symbol is interpreted by JavaScript as the start of a template literal (e.g., `${variable}`). To prevent this and ensure your command renders correctly in the terminal, you’ll need to escape `$` signs using a backslash (`\`). For example:

```jsx
<Terminal>
  {`$ docker run --detach --name step_1_2 -p 81:80 -v $(pwd):/var/www/html -u \${UID}:\${GID} php:8.1.5-apache`}
</Terminal>
```

This ensures that `\${UID}` and `\${GID}` are treated as literal strings rather than JavaScript expressions. Without escaping, your code may throw an error or render incorrectly.

## 📄 License

MIT — free to use and modify.

## 💬 AI generated

This code has been generated by Christophe Avonture using Google Gemini and Claude Code.
````

Then edit (or create) the `src/theme/MDXComponents.js` file. If the file already exists, just add the highlighted lines below. If not yet present, create the file with the content below:

**src/theme/MDXComponents.js**

```javascript
// At the top of your file, add this line:

// highlight-next-line
import Terminal from "@site/src/components/Terminal";

// Then in your export section, add this line too:
export default {
  // Reusing the default mapping
  ...MDXComponents,

  // [...]

  // highlight-next-line
  Terminal
};
```

From now one, you can use the `<Terminal>` component in your posts. Enjoy!

*That `src/theme/MDXComponents.js` file is the central registry of every custom component of your blog; [Change how Docusaurus will create img tags](/blog/docusaurus-override-img) shows how it can also override the built-in tags, not just add new ones.*
