---
title: "Pure CSS Motion"
summary: "No animation library: linear() spring easing, View Transitions, scroll-driven animation, @starting-style entrances, height to auto."
category: "motion (Motion)"
origin: "original"
stack: ["html","css"]
tags: ["motion","spring","page transitions","scroll-driven","zero-dependency"]
demo: "https://zhuoqidev.com/design/demos/css-only-motion/"
url: "https://zhuoqidev.com/en/design/css-only-motion/"
license: "MIT"
added: "2026-10-14"
translation: "https://zhuoqidev.com/design/css-only-motion/index.md"
---

# Pure CSS Motion

> No animation library: linear() spring easing, View Transitions, scroll-driven animation, @starting-style entrances, height to auto.

## What makes it work

- **Springs written as easing curves**: a damped spring is sampled into 31 points and put into `linear()`, giving an easing that overshoots and settles back. Two are enough: `--spring` (damping 0.5, about 16% overshoot) for press / drag / drop, and `--spring-soft` (about 8%) for windows and pages. The curve chart in the demo is drawn by reading these two CSS variables directly.
- **Page transitions**: `@view-transition { navigation: auto; }` is one line that gives same-site navigation a transition. The old page moves up and fades out, the new page settles in with the soft spring, and the clicked title flies into the next page's large heading through the same `view-transition-name`.
- **Scroll-driven**: the reading progress bar is `animation-timeline: scroll()`, and card entrances are `animation-timeline: view()` with `animation-range: entry 0% entry 75%`. There is no scroll listener at all.
- **First-frame entrance**: `@starting-style` describes how an element looks when first rendered. A toast newly inserted into the DOM transitions from that state, with no keyframes and no "wait a frame, then add a class".
- **Height to auto**: with `interpolate-size: allow-keywords` on the root, `height: 0 ↔ auto` can be transitioned, and it works with springs too.
- **Every piece degrades**: browsers that don't support a feature simply show the final state. The small badge at the top right of the demo tells you which features the current browser supports.

## How to use it

1. Copy the two variables `--spring` and `--spring-soft` from `:root`. For a button, write `transition: scale .45s var(--spring)` and `:active { scale: .93 }`.
2. For page transitions on a multi-page site, add `@media (prefers-reduced-motion: no-preference) { @view-transition { navigation: auto; } }` to your global CSS, and give the top bar and bottom nav each a fixed `view-transition-name` so they stay in place during navigation.
3. To make a title "fly" across: give the clicked title on the list page and the large heading on the detail page the same `view-transition-name` (only one element on a page may have that name at any moment), then copy the `::view-transition-group(vt-title)` blocks.
4. The scroll-driven rules are wrapped in `@supports (animation-timeline: view())` and `prefers-reduced-motion: no-preference`; copy the longhand property style used in the demo.

## Pitfalls

- The demo is a single page, so a cross-document `@view-transition` can't run in it. It uses the same-document `document.startViewTransition()` instead, with exactly the same effect as the pseudo-element approach. A real multi-page site uses `@view-transition` and does not need that JS.
- The `animation` shorthand resets `animation-timeline` to `auto`, so `animation-timeline` must come after the shorthand. A subtler issue: some CSS minifiers recombine several longhands into a shorthand the browser doesn't recognize, and the effect silently disappears, so check the build output.
- `view-transition-name` must be unique at any moment: if every list item hard-codes the same name, the transition is skipped outright. Set the name only on the clicked item, at click time.
- A `linear()` spring is only a "time → progress" curve and knows nothing about velocity: however fast you flick on release while dragging, the bounce-back follows the same curve. Real physics needs JS.
- "Reduce motion" has to be handled separately for the transition pseudo-elements: the universal selector `*` doesn't reach `::view-transition-*`.

## Reference implementation (full source)

Live demo: https://zhuoqidev.com/design/demos/css-only-motion/. 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>纯 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>
```
