<!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">> 感谢读到这里,下次见。</p>
<p class="ln cmd gap"><span class="p">$</span> cat contact.md</p>
<p class="ln o">
> <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">> 「写下来的东西,才算真的想清楚了。」 —— 小王</p>
<p class="ln cmd gap"><span class="p">$</span> exit</p>
<p class="ln o">> <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 & 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">> 小王 · 前端开发</p>
<p class="ln cmd gap"><span class="p">$</span> ls ~/desk</p>
<p class="ln o">> 12 篇文章 · 3 个项目</p>
<p class="ln cmd gap"><span class="p">$</span> open example.com</p>
<p class="ln o">
> <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>