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

← 全部灵感~/design/css-only-motion

动画效果原创

纯 CSS 动效

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

html · cssMIT收录于 #动效#弹簧#页面过渡#滚动驱动#零依赖

在线演示新窗口打开 ↗

看点

  • 弹簧写成缓动曲线:把阻尼弹簧采样成 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-*。
$给 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>纯 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>

来加个微信吧

刘卓琪的微信二维码

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

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