卓琪的开发笔记zhuoqidev.comRSSllms.txt

← 全部灵感~/design/terminal-signoff

动画效果原创

开场 / 告别终端

页脚是一段终端会话:see you、联系方式、fortune、exit 逐行打出来;另附一段首访开机动画。

html · css · jsMIT收录于 #终端#页脚#打字机#开场动画

在线演示新窗口打开 ↗

看点

  • 页脚当成一次告别:每页结尾都是同一段 shell 会话,echo "see you" → cat contact.md(联系方式)→ fortune(一句话)→ exit,最后一行 [Process completed] 用荧光黄标出来。
  • 一台笔记本:终端就是屏幕(10px 深色边框当屏幕边),下面一条 ::after 画的浅色机身,中间一个径向渐变的「开盖凹口」。机身比屏幕宽 7%,overflow-x: clip 保证手机上不撑出横向滚动。
  • 打字机靠 CSS:命令行用 clip-path: inset(0 100% 0 0) → inset(0 0 0 0) 配 steps(字数),一格一格露出来;输出行淡入上移。JS 只做一件事:给每行算好开始时间 --d 和字数 --n。
  • 第一次滚到才播:IntersectionObserver 看到页脚 30% 进入视口才加 .play,笔记本同时「开盖」(绕底边 rotateX)。
  • 开场动画:同一套行动画先打出 whoami / ls / open,进度条走满后整块放大 7 倍淡出,像镜头穿过屏幕进到页面里;任意点击、按键、滚轮都能跳过。
  • 扫描线:终端上盖一层 1px 间隔的半透明横纹,像旧显示器,几乎看不出来但有味道。

怎么用

  1. 拷 .laptop、.terminal、.tty、.ln 几段 CSS 和三个 @keyframes(ln-in、ln-type、lid-open)。
  2. 每行写成 <p class="ln cmd">(命令)或 <p class="ln o">(输出),多加一段空隙用 .gap。
  3. 拷 cue() 和 IntersectionObserver 那段脚本。想更克制:页面短到一打开就看得到页脚时,干脆不播,直接显示完成状态。
  4. 开场动画只在首页、第一次访问、宽屏时播:在 <head> 里用内联脚本判断(读 localStorage 标记)并给 <html> 加类,这样不会先闪一下正文;再加一个几秒的保底定时器,主脚本没跑起来也会自动撤掉遮罩。

坑

  • 命令行必须 width: fit-content,否则 clip-path 按整行宽度切,打字速度和字数对不上。
  • 字数要按折叠空白后的文本算(textContent.replace(/\s+/g, " ")),格式化工具把标签换行后,原始 textContent 会多出一堆空格。
  • 隐藏未播放的行要用「JS 加上 .armed 才隐藏」,不要默认 opacity: 0:没 JS、开了「减少动态效果」的人才能直接看到完整内容。
  • 开场的跳过监听要在点击按钮之后再挂(setTimeout 推迟一拍),不然触发它的那次点击会立刻把它跳过。
  • 扫描线那层 ::after 盖在整个终端上,必须 pointer-events: none,否则终端里的链接点不动。
$给 Agent 读这篇:index.md

源码 MIT 开源,拿去改。

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

来加个微信吧

刘卓琪的微信二维码

微信扫码,或在手机上长按识别

加好友请备注来意,不然可能会漏掉哦~