---
title: "纯 CSS 动效"
summary: "不引动画库：linear() 弹簧缓动、View Transitions 页面过渡、滚动驱动、@starting-style 入场、高度到 auto。"
category: "motion (动画效果)"
origin: "original"
stack: ["html","css"]
tags: ["动效","弹簧","页面过渡","滚动驱动","零依赖"]
demo: "https://zhuoqidev.com/design/demos/css-only-motion/"
url: "https://zhuoqidev.com/design/css-only-motion/"
license: "MIT"
added: "2026-10-14"
translation: "https://zhuoqidev.com/en/design/css-only-motion/index.md"
---

# 纯 CSS 动效

> 不引动画库：linear() 弹簧缓动、View Transitions 页面过渡、滚动驱动、@starting-style 入场、高度到 auto。

## 看点

- **弹簧写成缓动曲线**：把阻尼弹簧采样成 31 个点，塞进 `linear()`，就是一条会冲过头再回来的 easing。两条够用：`--spring`（阻尼 0.5，过冲约 16%）给按下 / 拖动 / 落下，`--spring-soft`（约 8%）给窗口和页面。演示里的曲线图是直接读这两个 CSS 变量画的。
- **页面过渡**：`@view-transition { navigation: auto; }` 一行让同站跳转自带过渡；旧页上移淡出，新页用软弹簧落下，被点的标题通过同一个 `view-transition-name` 飞成下一页的大标题。
- **滚动驱动**：阅读进度条是 `animation-timeline: scroll()`，卡片入场是 `animation-timeline: view()` 加 `animation-range: entry 0% entry 75%`，没有一行滚动监听。
- **首帧入场**：`@starting-style` 写元素第一次渲染时的样子，新插进 DOM 的提示条会从那里过渡过来，不用 keyframes，也不用「等一帧再加类」。
- **高度到 auto**：根上 `interpolate-size: allow-keywords`，`height: 0 ↔ auto` 就能过渡，还能配弹簧。
- **每块都能退化**：不支持的浏览器直接显示最终状态；演示右上角的小标签会告诉你当前浏览器支持哪些。

## 怎么用

1. 拷 `:root` 里的 `--spring`、`--spring-soft` 两个变量，按钮写 `transition: scale .45s var(--spring)`，`:active { scale: .93 }`。
2. 多页站点要页面过渡：全局 CSS 加 `@media (prefers-reduced-motion: no-preference) { @view-transition { navigation: auto; } }`，顶栏、底部导航各给一个固定的 `view-transition-name`，跳转时它们就原地不动。
3. 想让标题「飞」过去：列表页被点的标题和详情页的大标题设成同一个 `view-transition-name`（同一时刻一页里只能有一个元素叫这个名字），再拷 `::view-transition-group(vt-title)` 那几段。
4. 滚动驱动的规则包在 `@supports (animation-timeline: view())` 和 `prefers-reduced-motion: no-preference` 里，拷演示里的长属性写法。

## 坑

- 演示是单个页面，跨文档的 `@view-transition` 跑不起来，所以用的是同文档的 `document.startViewTransition()`，效果和伪元素写法完全一样。真实多页站点用 `@view-transition`，不需要这段 JS。
- `animation` 简写会把 `animation-timeline` 重置成 `auto`，所以 `animation-timeline` 必须写在简写之后。更隐蔽的是：有的 CSS 压缩工具会把几个长属性重新合成一个浏览器不认的简写，效果静默消失，要去检查构建产物。
- `view-transition-name` 在同一时刻必须唯一：列表里每一项都写死同一个名字，过渡会直接被跳过。只在点击时给被点的那一项设名字。
- `linear()` 弹簧只是「时间 → 进度」的曲线，不认速度：拖拽松手时甩得再快，回弹也是同一条曲线。要真物理就得上 JS。
- 「减少动态效果」要单独处理过渡伪元素：通配符 `*` 管不到 `::view-transition-*`。

## 参考实现（完整源码）

在线演示：https://zhuoqidev.com/design/demos/css-only-motion/。单文件，存成 .html 就能打开。

### 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>纯 CSS 动效</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-soft: #e8eefc;
        --blue-line: rgba(47, 91, 211, 0.35);
        --seal: #c0391f;
        --seal-soft: #fbe7e2;
        --green: #277a4b;
        --hi: #ffe066;
        --on-blue: #fff;
        --shadow: 0 1px 0 rgba(28, 34, 48, 0.04), 0 10px 28px -14px rgba(28, 34, 48, 0.28);
        --ease: cubic-bezier(0.16, 1, 0.3, 1);
        /* Two damped springs sampled into linear(): damping 0.5 overshoots about 16%, 0.62 about 8%. */
        --spring: linear(
          0,
          0.062,
          0.215,
          0.411,
          0.615,
          0.799,
          0.95,
          1.059,
          1.126,
          1.158,
          1.161,
          1.145,
          1.116,
          1.083,
          1.051,
          1.023,
          1.001,
          0.986,
          0.977,
          0.974,
          0.974,
          0.978,
          0.983,
          0.988,
          0.994,
          0.998,
          1.001,
          1.003,
          1.004,
          1.004,
          1
        );
        --spring-soft: linear(
          0,
          0.042,
          0.145,
          0.282,
          0.432,
          0.578,
          0.711,
          0.825,
          0.916,
          0.985,
          1.033,
          1.063,
          1.079,
          1.084,
          1.08,
          1.072,
          1.06,
          1.048,
          1.035,
          1.024,
          1.015,
          1.007,
          1.001,
          0.997,
          0.995,
          0.993,
          0.993,
          0.993,
          0.994,
          0.995,
          1
        );
        --f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
        --f-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
        --f-serif: "Songti SC", STSong, "Noto Serif CJK SC", "Source Han Serif SC", SimSun, Georgia, serif;
        /* Lets height transition to and from `auto` (Chrome 129+). */
        interpolate-size: allow-keywords;
        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-soft: #1f2a47;
          --blue-line: rgba(142, 170, 255, 0.45);
          --seal: #f0694e;
          --seal-soft: #3b211b;
          --green: #5cc189;
          --hi: #7d6416;
          --on-blue: #14161b;
          --shadow: 0 1px 0 rgba(0, 0, 0, 0.25), 0 10px 28px -14px rgba(0, 0, 0, 0.8);
          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: 15px/1.7 var(--f-sans);
      }
      header {
        max-width: 1220px;
        margin: 0 auto;
        padding: 22px 16px 6px;
      }
      h1 {
        margin: 0;
        font: 900 26px/1.3 var(--f-serif);
      }
      header p {
        margin: 2px 0 0;
        font-size: 14px;
        color: var(--ink-2);
      }
      code {
        font: 400 0.88em var(--f-mono);
        padding: 1px 5px;
        border-radius: 5px;
        background: var(--paper-2);
        border: 1px solid var(--line);
      }
      .grid {
        max-width: 1220px;
        margin: 0 auto;
        padding: 12px 16px 40px;
        display: grid;
        grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
        gap: 16px;
        align-items: start;
      }
      .card {
        min-width: 0;
        padding: 16px 18px 18px;
        background: var(--card);
        border: 2px dashed var(--blue-line);
        border-radius: 14px;
        box-shadow: var(--shadow);
      }
      .card h2 {
        display: flex;
        flex-wrap: wrap;
        gap: 4px 10px;
        align-items: baseline;
        margin: 0 0 4px;
        font: 700 17px/1.4 var(--f-serif);
      }
      .card h2 small {
        font: 500 11px/1 var(--f-mono);
        color: var(--seal);
        letter-spacing: 0.08em;
      }
      .card > p {
        margin: 0 0 12px;
        font-size: 13px;
        line-height: 1.65;
        color: var(--ink-2);
      }
      .support {
        display: inline-block;
        margin-left: auto;
        padding: 2px 7px;
        border-radius: 6px;
        font: 500 11px/1.4 var(--f-mono);
        color: var(--green);
        background: color-mix(in srgb, var(--green) 14%, transparent);
      }
      .support.no {
        color: var(--seal);
        background: var(--seal-soft);
      }
      .btn {
        font: 500 13px/1 var(--f-sans);
        color: var(--ink-2);
        padding: 8px 12px;
        border-radius: 10px;
        border: 1px dashed var(--ink-3);
        background: transparent;
        cursor: pointer;
        transition:
          scale 0.45s var(--spring),
          color 0.15s,
          background-color 0.15s;
      }
      .btn:hover {
        border-style: solid;
        color: var(--ink);
        background: var(--paper-2);
      }
      .btn:active {
        scale: 0.93;
      }
      .btn.solid {
        border: 1px solid var(--blue);
        background: var(--blue);
        color: var(--on-blue);
      }

      /* ---------- 1. springs as easing curves ---------- */
      .plot {
        display: block;
        width: 100%;
        height: auto;
        margin-bottom: 6px;
        overflow: visible;
      }
      .plot .axis {
        stroke: var(--line);
        stroke-width: 1;
      }
      .plot .one {
        stroke: var(--ink-3);
        stroke-dasharray: 4 4;
        stroke-width: 1;
      }
      .plot polyline {
        fill: none;
        stroke-width: 2.4;
        stroke-linejoin: round;
        stroke-linecap: round;
      }
      .plot text {
        font: 400 10px var(--f-mono);
        fill: var(--ink-3);
      }
      .c-ease {
        --c: var(--ink-3);
      }
      .c-spring {
        --c: var(--seal);
      }
      .c-soft {
        --c: var(--blue);
      }
      .plot polyline.c-ease,
      .plot polyline.c-spring,
      .plot polyline.c-soft {
        stroke: var(--c);
      }
      .race {
        display: grid;
        gap: 6px;
        margin: 4px 0 12px;
      }
      .lane {
        display: grid;
        grid-template-columns: 92px 1fr;
        align-items: center;
        gap: 8px;
        font: 400 11px/1.2 var(--f-mono);
        color: var(--ink-2);
      }
      .track {
        position: relative;
        height: 18px;
        border-bottom: 1px dashed var(--line);
      }
      .track i {
        position: absolute;
        left: 0;
        top: 2px;
        width: 14px;
        height: 14px;
        border-radius: 50%;
        background: var(--c);
        transition: left 0.9s var(--e);
      }
      .race.go .track i {
        left: calc(100% - 14px);
      }
      .c-ease i {
        --e: var(--ease);
      }
      .c-spring i {
        --e: var(--spring);
      }
      .c-soft i {
        --e: var(--spring-soft);
      }

      /* ---------- 2. view transition ---------- */
      .stage {
        view-transition-name: stage;
        min-height: 178px;
        padding: 12px 14px;
        border: 1px solid var(--line);
        border-radius: 12px;
        background: var(--paper);
      }
      .stage ul {
        list-style: none;
        margin: 0;
        padding: 0;
      }
      .stage li + li {
        border-top: 1px dashed var(--line);
      }
      .stage li button {
        display: grid;
        grid-template-columns: 40px 1fr;
        gap: 6px;
        width: 100%;
        padding: 9px 2px;
        border: 0;
        background: none;
        color: var(--ink);
        text-align: left;
        font: 600 14px/1.45 var(--f-serif);
        cursor: pointer;
      }
      .stage li button:hover {
        color: var(--blue);
      }
      .stage li .d {
        font: 400 11px/2 var(--f-mono);
        color: var(--ink-3);
      }
      .stage .title {
        width: fit-content;
      }
      .detail h3 {
        width: fit-content;
        margin: 0 0 8px;
        font: 900 21px/1.35 var(--f-serif);
      }
      .detail p {
        margin: 0 0 10px;
        font-size: 13px;
        color: var(--ink-2);
      }
      /* The page itself does not move; the stage lifts away and settles in, the clicked title glides. */
      ::view-transition-old(root),
      ::view-transition-new(root) {
        animation: none;
      }
      ::view-transition-old(stage) {
        animation: vt-out 0.2s ease-in both;
      }
      ::view-transition-new(stage) {
        animation: vt-in 0.5s var(--spring-soft) both;
      }
      ::view-transition-group(vt-title) {
        animation-duration: 0.55s;
        animation-timing-function: var(--spring-soft);
      }
      ::view-transition-old(vt-title),
      ::view-transition-new(vt-title) {
        height: 100%;
        object-fit: contain;
        object-position: left top;
      }
      @keyframes vt-out {
        to {
          opacity: 0;
          translate: 0 -12px;
        }
      }
      @keyframes vt-in {
        from {
          opacity: 0;
          translate: 0 20px;
        }
      }

      /* ---------- 3. scroll-driven ---------- */
      .scroller {
        position: relative;
        height: 200px;
        overflow-y: auto;
        border: 1px solid var(--line);
        border-radius: 12px;
        background: var(--paper);
        overscroll-behavior: contain;
      }
      .progress {
        position: sticky;
        top: 0;
        z-index: 2;
        height: 4px;
        background: var(--seal);
        transform-origin: 0 50%;
        scale: 0 1;
      }
      .scroller ol {
        list-style: none;
        margin: 0;
        padding: 10px 12px 12px;
        display: grid;
        gap: 10px;
      }
      .scroller li {
        padding: 10px 12px;
        border: 1px solid var(--line);
        border-radius: 10px;
        background: var(--card);
        font-size: 13px;
        color: var(--ink-2);
      }
      .scroller li b {
        font: 600 11px/1 var(--f-mono);
        color: var(--blue);
        margin-right: 6px;
      }
      @keyframes grow {
        from {
          scale: 0 1;
        }
        to {
          scale: 1 1;
        }
      }
      @keyframes rise {
        from {
          opacity: 0;
          translate: 0 36px;
          scale: 0.97;
        }
      }
      /* Longhands on purpose: some CSS minifiers fold `animation` + `animation-timeline` into one
         shorthand that the browser rejects, and the effect silently disappears. */
      @supports (animation-timeline: view()) {
        @media (prefers-reduced-motion: no-preference) {
          .progress {
            animation-name: grow;
            animation-timing-function: linear;
            animation-fill-mode: both;
            animation-timeline: scroll();
          }
          .scroller li {
            animation-name: rise;
            animation-timing-function: linear;
            animation-fill-mode: both;
            animation-timeline: view();
            animation-range: entry 0% entry 75%;
          }
        }
      }

      /* ---------- 4. @starting-style ---------- */
      .toasts {
        display: grid;
        gap: 8px;
        min-height: 136px;
        align-content: start;
        margin-top: 12px;
      }
      .toast {
        display: flex;
        gap: 10px;
        align-items: center;
        padding: 9px 12px;
        border: 1.5px solid var(--blue);
        border-radius: 10px;
        background: var(--blue-soft);
        color: var(--ink);
        font-size: 13px;
        transition:
          opacity 0.3s var(--ease),
          translate 0.55s var(--spring),
          scale 0.55s var(--spring);
      }
      .toast b {
        font: 600 11px/1 var(--f-mono);
        color: var(--blue);
      }
      /* The state an element starts from the first time it is rendered: no keyframes needed. */
      @starting-style {
        .toast {
          opacity: 0;
          translate: 0 14px;
          scale: 0.94;
        }
      }
      .toast.leaving {
        opacity: 0;
        scale: 0.96;
      }

      /* ---------- 5. height: auto ---------- */
      .fold {
        height: 0;
        overflow: hidden;
        transition: height 0.55s var(--spring-soft);
      }
      .fold.open {
        height: auto;
      }
      .fold-in {
        margin-top: 12px;
        padding: 12px 14px;
        border-left: 3px solid var(--seal);
        background: var(--paper);
        font-size: 13px;
        color: var(--ink-2);
      }
      .fold-in p {
        margin: 0 0 6px;
      }
      .fold-in p:last-child {
        margin: 0;
      }

      /* ---------- 6. press and hover ---------- */
      .feel {
        display: flex;
        flex-wrap: wrap;
        gap: 14px;
        align-items: center;
      }
      .lift {
        flex: 1 1 180px;
        padding: 14px 16px;
        border: 2px dashed var(--blue-line);
        border-radius: 12px;
        background: var(--paper);
        font: 700 15px/1.4 var(--f-serif);
        transition:
          transform 0.6s var(--spring),
          border-color 0.2s;
      }
      .lift:hover {
        transform: translateY(-3px) rotate(-0.4deg);
        border-style: solid;
        border-color: var(--blue);
      }
      .lift small {
        display: block;
        margin-top: 2px;
        font: 400 11px/1.4 var(--f-mono);
        color: var(--ink-3);
      }

      @media (prefers-reduced-motion: reduce) {
        *,
        *::before,
        *::after {
          animation: none !important;
          transition: none !important;
        }
        ::view-transition-group(*),
        ::view-transition-old(*),
        ::view-transition-new(*) {
          animation: none !important;
        }
      }
    </style>
  </head>
  <body>
    <header>
      <h1>纯 CSS 动效</h1>
      <p>
        不引动画库：弹簧缓动、页面过渡、滚动驱动、首帧入场、高度到
        auto。每块右上角显示你的浏览器支不支持；系统开了「减少动态效果」时全部关掉。
      </p>
    </header>

    <main class="grid">
      <section class="card">
        <h2><small>01</small>弹簧缓动 <code>linear()</code><span class="support" data-check="linear"></span></h2>
        <p>
          把阻尼弹簧采样成 31 个点写进 <code>linear()</code>：冲过头一点再回来。红线 = <code>--spring</code>（过冲约
          16%），蓝线 = <code>--spring-soft</code>（约 8%），灰线是普通的 ease-out。
        </p>
        <svg class="plot" id="plot" viewBox="0 0 320 132" aria-label="三条缓动曲线对比图" role="img">
          <line class="axis" x1="24" y1="118" x2="316" y2="118" />
          <line class="axis" x1="24" y1="6" x2="24" y2="118" />
          <line class="one" x1="24" y1="34" x2="316" y2="34" />
          <text x="0" y="37">1.0</text>
          <text x="0" y="121">0</text>
          <text x="290" y="130">时间</text>
        </svg>
        <div class="race" id="race">
          <div class="lane c-ease">
            <span>ease-out</span><span class="track"><i></i></span>
          </div>
          <div class="lane c-spring">
            <span>--spring</span><span class="track"><i></i></span>
          </div>
          <div class="lane c-soft">
            <span>--spring-soft</span><span class="track"><i></i></span>
          </div>
        </div>
        <button class="btn solid" type="button" id="race-btn">▶ 跑一次</button>
      </section>

      <section class="card">
        <h2><small>02</small>页面过渡 View Transitions<span class="support" data-check="vt"></span></h2>
        <p>点一篇：旧画面上移淡出、新画面弹着落下，被点的标题直接「飞」成下一页的大标题。</p>
        <div class="stage" id="stage"></div>
      </section>

      <section class="card">
        <h2><small>03</small>滚动驱动 <code>animation-timeline</code><span class="support" data-check="sda"></span></h2>
        <p>
          在框里滚动：顶上红条是阅读进度（<code>scroll()</code>），卡片进入视口时浮上来（<code>view()</code>）。全程没有一行
          JS。
        </p>
        <div class="scroller" tabindex="0" aria-label="可滚动的示例列表">
          <div class="progress"></div>
          <ol>
            <li><b>01</b>进度条跟着滚动条走，不监听 scroll 事件。</li>
            <li><b>02</b>卡片从下方 36px 浮上来，同时淡入。</li>
            <li><b>03</b><code>animation-range: entry 0% entry 75%</code> 决定什么时候播完。</li>
            <li><b>04</b>动画跑在合成线程，滚得再快也不掉帧。</li>
            <li><b>05</b>不支持的浏览器直接显示静止的内容。</li>
            <li><b>06</b>减少动态效果时整段关掉。</li>
            <li><b>07</b>再往下一点。</li>
            <li><b>08</b>到底了，进度条也满了。</li>
          </ol>
        </div>
      </section>

      <section class="card">
        <h2>
          <small>04</small>首帧入场 <code>@starting-style</code><span class="support" data-check="starting"></span>
        </h2>
        <p>
          新插入的元素第一次渲染时从 <code>@starting-style</code> 里的样子过渡过来，不用写
          keyframes，也不用等一帧再加类。
        </p>
        <button class="btn solid" type="button" id="toast-btn">+ 来一条提示</button>
        <div class="toasts" id="toasts" aria-live="polite">
          <div class="toast"><b>01</b>已保存草稿</div>
          <div class="toast"><b>02</b>复制好了</div>
        </div>
      </section>

      <section class="card">
        <h2>
          <small>05</small>高度到 auto <code>interpolate-size</code><span class="support" data-check="isize"></span>
        </h2>
        <p>
          以前 <code>height: 0 → auto</code> 不能过渡，只能量高度写死。现在根上一句
          <code>interpolate-size: allow-keywords</code> 就行，还能配弹簧。
        </p>
        <button class="btn" type="button" id="fold-btn" aria-expanded="false" aria-controls="fold">展开说明 ▾</button>
        <div class="fold" id="fold">
          <div class="fold-in">
            <p>展开的高度是内容自己撑出来的，换文字、换字号都不用改 CSS。</p>
            <p>不支持的浏览器会直接跳到展开状态，没有动画但不坏。</p>
          </div>
        </div>
      </section>

      <section class="card">
        <h2><small>06</small>按下回弹 · 悬停抬起<span class="support" data-check="linear"></span></h2>
        <p>
          按钮按下 <code>scale: .93</code>，松手用弹簧弹回；卡片悬停抬起 3px、轻轻歪一点。都是一行
          <code>transition</code>。
        </p>
        <div class="feel">
          <button class="btn" type="button">按住我</button>
          <button class="btn solid" type="button">再按我</button>
          <div class="lift">把鼠标放上来<small>translateY(-3px) rotate(-.4deg)</small></div>
        </div>
      </section>
    </main>

    <script>
      // ---------- support badges ----------
      const checks = {
        linear: CSS.supports("transition-timing-function", "linear(0, 1)"),
        vt: "startViewTransition" in document,
        sda: CSS.supports("animation-timeline: view()"),
        starting: "CSSStartingStyleRule" in window,
        isize: CSS.supports("interpolate-size: allow-keywords"),
      };
      const fallback = {
        linear: "退回线性",
        vt: "直接切换",
        sda: "静止显示",
        starting: "直接出现",
        isize: "直接展开",
      };
      for (const el of document.querySelectorAll("[data-check]")) {
        const ok = checks[el.dataset.check];
        el.textContent = ok ? "✓ 支持" : `✗ ${fallback[el.dataset.check]}`;
        el.classList.toggle("no", !ok);
      }

      // ---------- 1. draw the curves straight from the CSS variables ----------
      const css = getComputedStyle(document.documentElement);
      const linearPoints = (name) =>
        css
          .getPropertyValue(name)
          .replace(/^\s*linear\(|\)\s*$/g, "")
          .split(",")
          .map(Number);
      // cubic-bezier(.16, 1, .3, 1) sampled by solving x(t) with a few bisection steps.
      const bezier = (x1, y1, x2, y2) => (x) => {
        const f = (t, a, b) => 3 * a * t * (1 - t) ** 2 + 3 * b * t * t * (1 - t) + t ** 3;
        let lo = 0;
        let hi = 1;
        for (let i = 0; i < 24; i++) {
          const mid = (lo + hi) / 2;
          if (f(mid, x1, x2) < x) lo = mid;
          else hi = mid;
        }
        return f((lo + hi) / 2, y1, y2);
      };
      const ease = bezier(0.16, 1, 0.3, 1);
      const X = (t) => 24 + t * 292;
      const Y = (v) => 118 - v * 84;
      const plot = document.getElementById("plot");
      const line = (cls, values) => {
        const pl = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
        pl.setAttribute("class", cls);
        pl.setAttribute(
          "points",
          values.map((v, i) => `${X(i / (values.length - 1)).toFixed(1)},${Y(v).toFixed(1)}`).join(" "),
        );
        plot.append(pl);
      };
      line(
        "c-ease",
        Array.from({ length: 41 }, (_, i) => ease(i / 40)),
      );
      line("c-soft", linearPoints("--spring-soft"));
      line("c-spring", linearPoints("--spring"));

      const race = document.getElementById("race");
      document.getElementById("race-btn").addEventListener("click", () => race.classList.toggle("go"));

      // ---------- 2. same-document view transition ----------
      const posts = [
        { d: "10-12", t: "给按钮加一点弹簧", p: "按下缩小 7%，松手用 linear() 弹回来。" },
        { d: "10-08", t: "纸感配色的深色模式", p: "白天是奶油纸，夜里是深色书桌。" },
        { d: "10-03", t: "滚动驱动的阅读进度条", p: "一个 animation-timeline 替掉整段 JS。" },
      ];
      const stage = document.getElementById("stage");
      function renderList() {
        stage.innerHTML = `<ul>${posts
          .map(
            (p, i) =>
              `<li><button type="button" data-i="${i}"><span class="d">${p.d}</span><span class="title">${p.t}</span></button></li>`,
          )
          .join("")}</ul>`;
      }
      function renderDetail(i) {
        const p = posts[i];
        stage.innerHTML = `<div class="detail"><h3 style="view-transition-name: vt-title">${p.t}</h3><p>${p.p} 这里是正文的第一段，真实站点里这是另一张页面。</p><button class="btn" type="button" data-back>← 返回列表</button></div>`;
      }
      // Run a DOM change as a view transition where supported, otherwise just make the change.
      const swap = (change) => (document.startViewTransition ? document.startViewTransition(change) : change());
      stage.addEventListener("click", (e) => {
        const item = e.target.closest("[data-i]");
        if (item) {
          // Name only the clicked title, so exactly one element pairs with the next headline.
          item.querySelector(".title").style.viewTransitionName = "vt-title";
          swap(() => renderDetail(Number(item.dataset.i)));
        } else if (e.target.closest("[data-back]")) {
          swap(renderList);
        }
      });
      renderList();

      // ---------- 4. toasts that enter through @starting-style ----------
      const toasts = document.getElementById("toasts");
      const words = ["已保存草稿", "复制好了", "已加入稍后读", "收到你的留言"];
      let n = toasts.children.length;
      document.getElementById("toast-btn").addEventListener("click", () => {
        const t = document.createElement("div");
        t.className = "toast";
        t.innerHTML = `<b>${String(++n).padStart(2, "0")}</b>${words[n % words.length]}`;
        toasts.append(t);
        while (toasts.children.length > 3) toasts.firstElementChild.remove();
        setTimeout(() => {
          t.classList.add("leaving");
          setTimeout(() => t.remove(), 300);
        }, 2600);
      });

      // ---------- 5. fold ----------
      const fold = document.getElementById("fold");
      const foldBtn = document.getElementById("fold-btn");
      foldBtn.addEventListener("click", () => {
        const open = fold.classList.toggle("open");
        foldBtn.setAttribute("aria-expanded", String(open));
        foldBtn.textContent = open ? "收起说明 ▴" : "展开说明 ▾";
      });
    </script>
  </body>
</html>
```
