<!--
  canonical-url: https://www.avonture.be/fr/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.
-->

# Donnez vie à vos tutoriels CLI avec un terminal machine à écrire

> Ajoutez une animation machine à écrire à votre composant Terminal Docusaurus — les lignes de commande tapées caractère par caractère, les lignes de sortie qui apparaissent l'une après l'autre, avec un curseur clignotant et un clic pour passer l'animation. Trois nouvelles props, aucune rupture de compatibilité.

---
![Donnez vie à vos tutoriels CLI avec un terminal machine à écrire](/img/v2/typewriter_terminal.webp)

**TL;DR**

Un bloc de code statique explique ce qu'il faut lancer. Un terminal animé montre ce qui *se passe*. Cet article étend le composant `Terminal` existant avec une prop `typewriter` : les lignes de commande (celles qui commencent par `$` ou `#`) sont tapées caractère par caractère avec un curseur clignotant ; les lignes de sortie apparaissent d'un bloc après une courte pause. Trois nouvelles props contrôlent la vitesse et le rythme. Le changement est totalement rétrocompatible — chaque bloc `<Terminal>` existant reste identique tant que vous ne l'activez pas. L'implémentation, c'est du state React pur + `setTimeout`, sans aucune librairie.

Les tutoriels CLI sont denses. Le lecteur suit, copie-colle les commandes, et espère que la sortie affichée correspond à ce que vous avez écrit. Mais quand l'article ne montre qu'un bloc de texte figé, la séquence des événements — la commande, la pause, la réponse — s'effondre en un seul mur de monospace.

Une animation machine à écrire restitue cette séquence. La commande apparaît lettre par lettre, comme si quelqu'un la tapait vraiment. Puis la sortie suit. L'œil du lecteur est guidé naturellement à travers l'interaction, sans explication supplémentaire.

**Quick Jump:** [La nouvelle prop typewriter](#the-new-typewriter-prop) | [Créer le composant](#create-the-component)

Si vous lisez régulièrement mon blog, vous savez que j'ai développé un composant appelé `Terminal` qui me permet de lister une série de commandes que j'ai lancées dans une console de style Linux — et le rendu est plutôt réaliste. C'est le pendant « console » du [composant que j'utilise pour afficher des snippets de code](/blog/docusaurus-snippets). Voici un exemple :

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

Il manque juste un peu de « vie » ; voyons comment le rendre vraiment sympa avec une animation de type machine à écrire.

La sortie du Terminal ci-dessus a été obtenue avec ce bloc dans mon contenu Markdown :

```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 fait ensuite le rendu lors de la prévisualisation de ce post ou lors de la génération du HTML.

## La nouvelle prop `typewriter` {#the-new-typewriter-prop}

Ajoutez `typewriter` pour activer l'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>
```

Voici ce qui se passe, image par image :

1. `$` apparaît, puis `d`, puis `o`, puis `c`… la commande se tape toute seule, caractère par caractère
2. Un curseur vert clignotant `▋` suit la position courante
3. Une fois la commande entièrement tapée, une courte pause la laisse se poser
4. `[+] Running 3/3` apparaît comme une ligne complète
5. Chaque ligne de sortie suit dans l'ordre
6. Le curseur disparaît à la fin de l'animation

> **Passer l'animation:**
>
> Cliquez n'importe où sur le terminal pour sauter à la fin. Pratique pour les lecteurs qui ont déjà vu l'animation et veulent juste copier les commandes.

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

Un autre exemple :

```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%
```

## L'animation ne démarre que si le terminal est visible {#animation-starts-only-when-visible}

Un terminal enterré au bas d'un long article aurait fini son animation bien avant que le lecteur n'y arrive. Le composant règle ça avec l'[Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) : l'effet machine à écrire démarre seulement quand au moins 10 % du terminal entre dans le viewport. Faites défiler la page et l'animation vous accueille pile au moment où vous arrivez.

> **Tip:**
>
> Envie de vérifier ? Rechargez simplement cette page (`CTRL`+`F5`) et appuyez sur End pour sauter à la conclusion de ce post. Puis remontez. Les animations démarrent. Tout simplement parce que le composant Terminal se trouve maintenant dans la partie visible de la page.

## La vitesse s'adapte automatiquement au nombre de lignes {#speed-scales-automatically-with-line-count}

Deux props supplémentaires permettent d'ajuster finement le rythme, mais vous en aurez rarement besoin — le composant s'ajuste tout seul selon le nombre de lignes :

| Lignes | `typewriterSpeed` | `typewriterLineDelay` | Durée totale approx. |
| ------ | ----------------- | --------------------- | -------------------- |
| ≤ 5    | 40 ms/car.        | 400 ms/ligne          | \~2 s                |
| 6–10   | 25 ms/car.        | 200 ms/ligne          | \~2,5 s              |
| 11–20  | 20 ms/car.        | 150 ms/ligne          | \~3,5 s              |
| > 20   | 12 ms/car.        | 100 ms/ligne          | \~3,5 s              |

Une session de 30 lignes se termine à peu près dans le même temps qu'une de 3 lignes. L'animation reste accrocheuse sans devenir une salle d'attente.

Ne surchargez ces valeurs que si vous cherchez un effet précis — une introduction lente et posée à un nouvel outil :

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

0.54.3 (brew)
</Terminal>
```

## Comment les lignes sont classées {#how-the-lines-are-classified}

L'animation distingue deux types de lignes en regardant les premiers caractères non blancs :

* **Lignes de commande** — commencent par `$` ou `#`. Tapées caractère par caractère à `typewriterSpeed` ms/car.
* **Lignes de sortie** — tout le reste. Affichées d'un bloc après `typewriterLineDelay` ms.
* **Lignes vides** — apparaissent après une pause de 80 ms, ce qui préserve la respiration visuelle du contenu d'origine.

Autrement dit, vous n'avez pas besoin d'annoter votre contenu d'une manière particulière. Écrivez la session de terminal naturellement ; le composant en déduit la structure.

## Aucune rupture de compatibilité {#zero-breaking-changes}

Chaque bloc `<Terminal>` existant dans le code continue de s'afficher exactement comme avant. La prop `typewriter` vaut `false` par défaut, donc le comportement statique reste celui par défaut.

Si vous voulez activer l'animation partout d'un coup, un rechercher-remplacer global de `<Terminal` → `<Terminal typewriter` sur le répertoire `blog/` suffit :

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

## Aperçu de l'implémentation {#implementation-sketch}

Le composant conserve trois morceaux de state pendant l'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)
```

Un seul `useEffect` pilote le tick de l'animation. À chaque rendu, il décide quel timeout planifier ensuite :

* Si la ligne courante est une commande et que `charIdx < line.length` → planifier `setCharIdx(c => c + 1)` après `typewriterSpeed` ms
* Sinon (ligne de sortie, ou commande entièrement tapée) → planifier le passage à la ligne suivante après `typewriterLineDelay` ms

Quand `lineIdx` atteint la fin du tableau de lignes, `animDone` passe à `true` et le composant revient au rendu du nœud React `{children}` d'origine — la coloration syntaxique et le balisage riche des enfants sont donc préservés une fois l'animation terminée.

Le curseur clignotant, c'est une simple animation CSS :

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

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

Pas de librairie externe. Pas de canvas. Juste du state et des timers.

## Créer le composant {#create-the-component}

Vous devrez créer trois nouveaux fichiers dans votre propre site Docusaurus. Le quatrième est optionnel : c'est la documentation du composant.

**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.
````

Éditez ensuite (ou créez) le fichier `src/theme/MDXComponents.js`. Si le fichier existe déjà, ajoutez simplement les lignes surlignées ci-dessous. S'il n'existe pas encore, créez-le avec le contenu ci-dessous :

**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
};
```

À partir de maintenant, vous pouvez utiliser le composant `<Terminal>` dans vos posts. Amusez-vous bien !

*Ce fichier `src/theme/MDXComponents.js` est le registre central de tous les composants personnalisés de votre blog ; [Change how Docusaurus will create img tags](/blog/docusaurus-override-img) montre comment il permet aussi de surcharger les balises natives, et pas seulement d'en ajouter de nouvelles.*
