---
title: "Opening / Sign-off Terminal"
summary: "The footer is a terminal session: see you, contact info, fortune and exit are typed out line by line; plus a first-visit boot animation."
category: "motion (Motion)"
origin: "original"
stack: ["html","css","js"]
tags: ["terminal","footer","typewriter","intro animation"]
demo: "https://zhuoqidev.com/design/demos/terminal-signoff/"
url: "https://zhuoqidev.com/en/design/terminal-signoff/"
license: "MIT"
added: "2026-10-14"
translation: "https://zhuoqidev.com/design/terminal-signoff/index.md"
---

# Opening / Sign-off Terminal

> The footer is a terminal session: see you, contact info, fortune and exit are typed out line by line; plus a first-visit boot animation.

## What makes it work

- **The footer as a farewell**: every page ends with the same shell session: `echo "see you"` → `cat contact.md` (contact info) → `fortune` (a single line) → `exit`, and the last line `[Process completed]` is marked in highlighter yellow.
- **A laptop**: the terminal is the screen (a 10px dark border serves as the screen bezel), below it a light-colored body drawn with `::after`, with a radial-gradient "lid notch" in the middle. The body is 7% wider than the screen, and `overflow-x: clip` keeps it from causing horizontal scrolling on phones.
- **The typewriter is CSS**: command lines use `clip-path: inset(0 100% 0 0)` → `inset(0 0 0 0)` with `steps(character count)`, revealing one character at a time; output lines fade in while moving up. JS does just one thing: compute each line's start time `--d` and character count `--n`.
- **Plays when first scrolled to**: an `IntersectionObserver` adds `.play` only once 30% of the footer enters the viewport, and the laptop "opens its lid" at the same time (`rotateX` around the bottom edge).
- **Intro animation**: the same line animations first type out `whoami` / `ls` / `open`, and when the progress bar fills, the whole block scales up 7x and fades out, like the camera passing through the screen into the page; any click, key press or wheel skips it.
- **Scanlines**: a translucent horizontal stripe layer at 1px spacing covers the terminal like an old monitor; barely noticeable but adds flavor.

## How to use it

1. Copy the CSS blocks `.laptop`, `.terminal`, `.tty`, `.ln` and the three `@keyframes` (`ln-in`, `ln-type`, `lid-open`).
2. Write each line as `<p class="ln cmd">` (command) or `<p class="ln o">` (output); use `.gap` for an extra blank space.
3. Copy the `cue()` and `IntersectionObserver` script. For a more restrained version: if the page is short enough that the footer is visible as soon as it opens, simply don't play and show the finished state directly.
4. Play the intro animation only on the homepage, on the first visit, on wide screens: decide with an inline script in `<head>` (reading a `localStorage` flag) and add a class to `<html>`, so the body text doesn't flash first; also add a fallback timer of a few seconds so the overlay is removed automatically even if the main script never runs.

## Pitfalls

- Command lines must have `width: fit-content`, otherwise `clip-path` cuts by the full line width and the typing speed doesn't match the character count.
- Count characters from the text after collapsing whitespace (`textContent.replace(/\s+/g, " ")`); once a formatter puts tags on separate lines, the raw `textContent` contains a lot of extra spaces.
- Hide unplayed lines with "hidden only when JS adds `.armed`", not with a default `opacity: 0`: this way people without JS, or with "reduce motion" on, can see the full content directly.
- Attach the intro's skip listeners only after the button click (defer one tick with `setTimeout`), otherwise the very click that triggered it skips it immediately.
- The `::after` scanline layer covers the whole terminal and must have `pointer-events: none`, otherwise links in the terminal can't be clicked.

## Reference implementation (full source)

Live demo: https://zhuoqidev.com/design/demos/terminal-signoff/. One file: save it as .html and open it.

### index.html

```html
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>开场 / 告别终端</title>
    <style>
      :root {
        --paper: #fbf8f1;
        --paper-2: #f3eee2;
        --card: #fffdf8;
        --ink: #1c2230;
        --ink-2: #4a5163;
        --ink-3: #636877;
        --line: #e6dfd0;
        --dot: #d8d0bf;
        --blue: #2f5bd3;
        --blue-line: rgba(47, 91, 211, 0.35);
        /* The terminal is dark in both themes; only the page around it changes. */
        --term: #161a22;
        --term-ink: #d7dce6;
        --term-green: #5fd38a;
        --yellow: #ffe066;
        --ease: cubic-bezier(0.16, 1, 0.3, 1);
        --f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
        --f-mono: ui-monospace, "SF Mono", Menlo, Consolas, "Noto Sans Mono CJK SC", monospace;
        color-scheme: light;
      }
      @media (prefers-color-scheme: dark) {
        :root {
          --paper: #14161b;
          --paper-2: #1c1f26;
          --card: #1e2129;
          --ink: #ece7dc;
          --ink-2: #bab6ac;
          --ink-3: #959aa8;
          --line: #31353f;
          --dot: #2c3039;
          --blue: #8eaaff;
          --blue-line: rgba(142, 170, 255, 0.45);
          --term: #0d0f13;
          color-scheme: dark;
        }
      }
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        min-height: 100vh;
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
        color: var(--ink);
        font: 16px/1.7 var(--f-sans);
      }
      a {
        color: inherit;
      }

      /* ---------- demo controls (not part of the component) ---------- */
      .controls {
        display: flex;
        flex-wrap: wrap;
        gap: 8px;
        justify-content: center;
        padding: 20px 16px 8px;
      }
      .controls button {
        font: 500 13px/1 var(--f-sans);
        color: var(--ink-2);
        padding: 8px 12px;
        border: 1px dashed var(--ink-3);
        border-radius: 10px;
        background: transparent;
        cursor: pointer;
      }
      .controls button:hover {
        border-style: solid;
        color: var(--ink);
        background: var(--card);
      }
      .end {
        margin: 6px 0 0;
        text-align: center;
        font: 400 12px/1.6 var(--f-mono);
        color: var(--ink-3);
        letter-spacing: 0.2em;
      }

      /* ---------- footer ---------- */
      .site-footer {
        margin-top: 22px;
        padding: 26px 16px 40px;
        text-align: center;
        font: 400 12px/1.9 var(--f-mono);
        color: var(--ink-3);
        border-top: 2px dashed var(--blue-line);
        overflow-x: clip; /* the base slab is wider than the screen; never widen the page */
      }
      .back-top {
        display: inline-flex;
        flex-direction: column;
        align-items: center;
        gap: 4px;
        margin-bottom: 18px;
        color: var(--ink-3);
        letter-spacing: 0.14em;
        text-decoration: none;
      }
      .back-top:hover {
        color: var(--ink);
      }
      .back-top span {
        font-size: 18px;
        animation: float-up 2s ease-in-out infinite;
      }
      @keyframes float-up {
        0%,
        100% {
          opacity: 0.5;
          translate: 0 0;
        }
        50% {
          opacity: 1;
          translate: 0 -6px;
        }
      }

      /* A laptop: the terminal is its screen, a light slab with a thumb notch is its base. */
      .laptop {
        max-width: 600px;
        margin: 0 auto;
      }
      .terminal {
        position: relative;
        overflow: hidden;
        background: var(--term);
        color: var(--term-ink);
        text-align: left;
        border: 10px solid #262a33;
        border-bottom-width: 14px;
        border-radius: 16px 16px 6px 6px;
      }
      /* Faint scanlines, like an old monitor. */
      .terminal::after {
        content: "";
        position: absolute;
        inset: 0;
        pointer-events: none;
        background: repeating-linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0 1px, transparent 1px 3px);
      }
      .laptop::after {
        content: "";
        display: block;
        height: 12px;
        margin: 0 -7%;
        border-radius: 2px 2px 14px 14px;
        background: radial-gradient(56px 5px at 50% 0, #9aa0aa 98%, transparent), linear-gradient(#e1e4e9, #aeb3bc);
        box-shadow: 0 22px 26px -16px rgba(28, 34, 48, 0.45);
      }
      .bar {
        display: flex;
        gap: 7px;
        align-items: center;
        padding: 11px 14px;
        background: #1f2430;
      }
      .bar i {
        width: 11px;
        height: 11px;
        border-radius: 50%;
        background: #ff5f57;
      }
      .bar i:nth-child(2) {
        background: #febc2e;
      }
      .bar i:nth-child(3) {
        background: #28c840;
      }
      .bar span {
        margin-left: auto;
        font: 400 11px/1 var(--f-mono);
        color: #8b93a4;
      }

      /* A shell session written as lines: commands type out, their output fades in. */
      .tty {
        padding: 18px 24px 16px;
        font: 400 13px/1.75 var(--f-mono);
      }
      .ln {
        margin: 0;
        overflow-wrap: anywhere;
      }
      .ln.gap {
        margin-top: 14px;
      }
      .ln.cmd {
        width: fit-content;
        color: #f2efe6;
        font-weight: 600;
      }
      .ln.o {
        padding-left: 1.3em;
        color: #9aa3b5;
      }
      .ln.f {
        color: #b7bfcf;
      }
      .p {
        color: var(--term-green);
      }
      .y {
        background: var(--yellow);
        color: #161a22;
        padding: 1px 6px;
        border-radius: 3px;
      }
      .bye .ln a {
        color: var(--yellow);
        text-decoration: none;
        border-bottom: 1px dashed rgba(255, 224, 102, 0.45);
      }
      .bye .ln a:hover {
        border-bottom-style: solid;
      }
      .cursor {
        display: inline-block;
        width: 8px;
        height: 15px;
        background: var(--term-green);
        vertical-align: -2px;
        animation: blink 1.1s steps(1) infinite;
      }
      @keyframes blink {
        50% {
          opacity: 0;
        }
      }
      .bye-foot {
        display: flex;
        flex-wrap: wrap;
        justify-content: center;
        gap: 2px 14px;
        padding: 9px 16px 11px;
        border-top: 1px solid rgba(255, 255, 255, 0.08);
        font: 400 11px/1.7 var(--f-mono);
        color: #8b93a4;
      }
      .bye-foot a {
        color: #a9b0bf;
        text-decoration: none;
      }
      .bye-foot a:hover {
        color: var(--yellow);
      }

      /* Playback. JS adds .armed (hide the lines) and later .play (run them); each line gets its
         start time in --d and, for commands, its character count in --n. */
      .armed:not(.play) .ln {
        opacity: 0;
      }
      .play .ln {
        animation: ln-in 0.3s var(--d, 0s) ease-out both;
      }
      .play .ln.cmd {
        animation: ln-type calc(var(--n, 12) * 30ms) var(--d, 0s) steps(var(--n, 12)) both;
      }
      .armed .laptop {
        transform-origin: 50% 100%;
      }
      .armed.play .laptop {
        animation: lid-open 0.7s var(--ease) both;
      }
      @keyframes ln-in {
        from {
          opacity: 0;
          translate: 0 4px;
        }
      }
      @keyframes ln-type {
        from {
          clip-path: inset(0 100% 0 0);
        }
        to {
          clip-path: inset(0 0 0 0);
        }
      }
      /* The lid swings on its hinge (bottom edge), so the near edge never grows wider than the page. */
      @keyframes lid-open {
        from {
          opacity: 0.35;
          transform: perspective(1100px) rotateX(32deg) translateY(24px);
        }
      }

      /* ---------- boot screen: plays once, then zooms through the screen into the page ---------- */
      .boot {
        position: fixed;
        inset: 0;
        z-index: 100;
        display: grid;
        place-items: center;
        align-content: center;
        gap: 26px;
        padding: 16px;
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
      }
      .boot[hidden] {
        display: none;
      }
      .boot .laptop {
        width: min(560px, 100%);
      }
      .boot-bar {
        display: inline-block;
        width: min(220px, 50vw);
        height: 9px;
        vertical-align: 0;
        border: 1px solid #3a404c;
        border-radius: 5px;
        overflow: hidden;
      }
      .boot-bar i {
        display: block;
        height: 100%;
        width: 0;
        background: var(--term-green);
      }
      .play .boot-bar i {
        animation: bar-fill 0.8s calc(var(--d, 0s) + 0.1s) var(--ease) forwards;
      }
      @keyframes bar-fill {
        to {
          width: 100%;
        }
      }
      .boot-skip {
        margin: 0;
        font: 400 12px/1 var(--f-mono);
        color: var(--ink-3);
        letter-spacing: 0.06em;
      }
      .boot.zoom {
        animation: boot-out 0.7s ease-in forwards;
      }
      .boot.zoom .laptop {
        animation: boot-zoom 0.7s cubic-bezier(0.7, 0, 0.84, 0) forwards;
      }
      .boot.skip {
        animation: boot-out 0.25s ease-out forwards;
      }
      @keyframes boot-zoom {
        to {
          scale: 7;
          opacity: 0;
        }
      }
      @keyframes boot-out {
        55% {
          opacity: 1;
        }
        to {
          opacity: 0;
        }
      }

      @media (max-width: 640px) {
        .terminal {
          border-width: 7px 7px 10px;
        }
        .laptop::after {
          margin: 0 -10px;
          height: 10px;
        }
        .tty {
          padding: 14px 16px 12px;
          font-size: 12.5px;
        }
      }
      @media (prefers-reduced-motion: reduce) {
        *,
        *::before,
        *::after {
          animation: none !important;
          transition: none !important;
        }
      }
    </style>
  </head>
  <body>
    <div class="controls">
      <button type="button" id="replay">↻ 重播告别</button>
      <button type="button" id="boot-btn">▶ 播放开场</button>
    </div>
    <p class="end">— 全文完 —</p>

    <footer class="site-footer">
      <a class="back-top" href="#">
        <span aria-hidden="true">↑</span>
        回到顶部
      </a>
      <div class="bye" data-bye>
        <div class="laptop">
          <div class="terminal">
            <div class="bar">
              <i></i>
              <i></i>
              <i></i>
              <span>xiaowang@desk — zsh</span>
            </div>
            <div class="tty">
              <p class="ln cmd"><span class="p">$</span> echo "see you"</p>
              <p class="ln o">&gt; 感谢读到这里，下次见。</p>
              <p class="ln cmd gap"><span class="p">$</span> cat contact.md</p>
              <p class="ln o">
                &gt; <a href="#">RSS</a> · <a href="#">留言板</a> · <a href="mailto:hi@example.com">hi@example.com</a>
              </p>
              <p class="ln cmd gap"><span class="p">$</span> fortune</p>
              <p class="ln o f" id="fortune">&gt; 「写下来的东西，才算真的想清楚了。」 —— 小王</p>
              <p class="ln cmd gap"><span class="p">$</span> exit</p>
              <p class="ln o">&gt; <span class="y">[Process completed]</span></p>
              <p class="ln gap"><span class="p">$</span> <span class="cursor"></span></p>
            </div>
            <div class="bye-foot">
              <span>© 2026 小王</span>
              <a href="#">Sitemap</a>
              <a href="#">隐私</a>
              <span>Built with HTML &amp; CSS</span>
            </div>
          </div>
        </div>
      </div>
    </footer>

    <div class="boot" id="boot" hidden aria-hidden="true">
      <div class="laptop">
        <div class="terminal">
          <div class="bar">
            <i></i>
            <i></i>
            <i></i>
            <span>xiaowang@desk — zsh</span>
          </div>
          <div class="tty">
            <p class="ln cmd"><span class="p">$</span> whoami</p>
            <p class="ln o">&gt; 小王 · 前端开发</p>
            <p class="ln cmd gap"><span class="p">$</span> ls ~/desk</p>
            <p class="ln o">&gt; 12 篇文章 · 3 个项目</p>
            <p class="ln cmd gap"><span class="p">$</span> open example.com</p>
            <p class="ln o">
              &gt; <span class="boot-bar"><i></i></span>
            </p>
          </div>
        </div>
      </div>
      <p class="boot-skip">点击任意处或按任意键跳过</p>
    </div>

    <script>
      const still = matchMedia("(prefers-reduced-motion: reduce)");

      // Give every line its start time: a command types at 30ms a character, an output line shows
      // 0.16s after the previous one. Returns when the last line starts.
      function cue(tty, start = 0.15) {
        let t = start;
        for (const ln of tty.querySelectorAll(".ln")) {
          ln.style.setProperty("--d", `${t.toFixed(2)}s`);
          if (ln.classList.contains("cmd")) {
            const n = ln.textContent.replace(/\s+/g, " ").trim().length;
            ln.style.setProperty("--n", String(n));
            t += n * 0.03 + 0.12;
          } else {
            t += 0.16;
          }
        }
        return t;
      }

      // ---------- goodbye terminal ----------
      const fortunes = [
        "「写下来的东西，才算真的想清楚了。」 —— 小王",
        "「慢一点没关系，别停下就好。」 —— 小王",
        "「今天修好的 bug，明天会变成别人的经验。」 —— 小王",
      ];
      const bye = document.querySelector("[data-bye]");
      const fortune = document.getElementById("fortune");
      let nth = 0;

      function playBye() {
        bye.classList.remove("play");
        void bye.offsetWidth; // restart the CSS animations
        bye.classList.add("play");
      }
      // Without JS, or with reduced motion, the session simply sits there finished.
      if (!still.matches) {
        cue(bye.querySelector(".tty"), 0.5);
        bye.classList.add("armed");
        // Play the first time it scrolls into view (in a real footer that is the end of the page).
        const io = new IntersectionObserver(
          (entries) => {
            if (entries.some((e) => e.isIntersecting)) {
              playBye();
              io.disconnect();
            }
          },
          { threshold: 0.3 },
        );
        io.observe(bye);
      }
      document.getElementById("replay").addEventListener("click", () => {
        fortune.textContent = `> ${fortunes[++nth % fortunes.length]}`;
        if (!still.matches) playBye();
      });

      // ---------- boot screen ----------
      // A real site shows this once, on the first visit to the home page (decided by an inline
      // script in <head> so the page never flashes first). Here a button starts it.
      const boot = document.getElementById("boot");
      const skipOn = ["pointerdown", "keydown", "wheel", "touchstart"];
      let timer = 0;

      function finish(skipped) {
        clearTimeout(timer);
        for (const type of skipOn) removeEventListener(type, skip);
        boot.classList.add(skipped ? "skip" : "zoom");
        setTimeout(
          () => {
            boot.hidden = true;
            boot.classList.remove("zoom", "skip");
            if (!still.matches) playBye();
          },
          skipped ? 250 : 700,
        );
      }
      function skip() {
        finish(true);
      }
      document.getElementById("boot-btn").addEventListener("click", () => {
        if (still.matches) return;
        const tty = boot.querySelector(".tty");
        tty.classList.remove("play");
        tty.classList.add("armed");
        boot.hidden = false;
        const end = cue(tty, 0.3);
        void tty.offsetWidth;
        tty.classList.add("play");
        // The bar starts filling with the last line and takes 0.8s; then zoom through the screen.
        timer = setTimeout(() => finish(false), (end + 0.9) * 1000);
        // Listen after this click has finished, or it would count as a skip.
        setTimeout(() => {
          for (const type of skipOn) addEventListener(type, skip, { passive: true });
        });
      });
    </script>
  </body>
</html>
```
