---
title: "落款盖章开场"
summary: "首访全屏开场：「卓琪」按真实笔顺一笔一笔写出来，石章压下、抬走留下湿印，印痕飞回顶栏 Logo，页面从 Logo 处洇开。"
category: "motion (动画效果)"
origin: "original"
stack: ["html","css","js","svg"]
tags: ["开场动画","笔顺书写","印章","遮罩揭幕","Web Animations"]
demo: "https://zhuoqidev.com/design/demos/stamp-opening/"
url: "https://zhuoqidev.com/design/stamp-opening/"
license: "MIT"
added: "2026-10-24"
translation: "https://zhuoqidev.com/en/design/stamp-opening/index.md"
---

# 落款盖章开场

> 首访全屏开场：「卓琪」按真实笔顺一笔一笔写出来，石章压下、抬走留下湿印，印痕飞回顶栏 Logo，页面从 Logo 处洇开。

本站首页第一次打开时的开场（宽屏、第一次来才播，约 4.4 秒）。开场全部用站点自己的东西：名字、印章 Logo、印章红，最后落在真正的 Logo 上再揭开页面，不是一块和页面无关的遮罩。

## 看点

- **按真实笔顺书写**：每个字的每一笔都有一条轮廓和一条中线（数据来自 Make Me a Hanzi）。把轮廓做成 `clipPath`，沿中线画一条 200 单位粗的线，再用 `stroke-dashoffset` 把线一点点画出来，看起来就是笔顺着笔画走。两个字共 20 笔、1.55 秒，按每笔的长度分配时间，笔与笔之间停 22 毫秒，两个字之间多停 120 毫秒。
- **墨迹有毛边**：整组笔画套一层 `feTurbulence` + `feDisplacementMap` 滤镜，边缘洇开 3px，不再是矢量的光滑边。
- **一方石章，不是一个红方块**：先看到的是从上往下看的章身（米色石面、顶上一个小钮、底边一圈蘸了印泥的红），带着由虚到实的投影落下来，中途还往上提一下蓄力，再压下去。
- **落下那一下**：纸面整体下沉 3px 再弹回、印章周围一圈压痕扩散、七个印泥点溅开，同时出现红色印痕。
- **湿印会干**：章抬走后印痕上有一层 `soft-light` 的斜向反光，一秒内淡掉；印痕本身用噪声做了缺墨和毛边。
- **印痕飞回 Logo 再揭幕**：印痕沿一道上拱的弧线飞到顶栏 Logo 上（量 Logo 的真实位置，落点误差 2px 内），落定后页面才从 Logo 处打开：遮罩用 `mask: radial-gradient()`，洞的边缘羽化 70px，再加一圈印泥红的涟漪跟着边走。
- **镜头慢慢推近**：整段开场里，画面以 3.5% 的幅度缓慢放大；印痕起飞前停住，否则落点会算偏。

## 怎么用

1. 用到的字体（龙藏体、Caveat、思源宋体 900）和笔顺数据都从 jsDelivr 按固定版本加载，demo 里只有自己的代码；笔顺数据是 [hanzi-writer-data](https://github.com/chanind/hanzi-writer-data)（Arphic Public License），自己的站里内联它时要带上 `ARPHICPL.TXT`。换成别的字，把 `["卓", "琪"]` 换掉即可。
2. 时间线全部用 Web Animations（`el.animate()`）排，在脚本开头的注释里写着每一步从第几毫秒开始。勾上「慢放 ¼×」可以一步一步看；网址加 `?t=2800` 会停在那个时刻（3100 毫秒以内）。
3. 放进真站时：在 `<head>` 的内联脚本里判断要不要播（第一次来、宽屏、没开「减少动效」）并给 `<html>` 加类，遮罩默认 `display: none`，这样不跑 JS 的人和爬虫直接看到页面；动画脚本和笔顺数据用 `import()` 只在要播时加载。
4. 任意点击、按键、滚轮、触摸都立刻跳过。

## 坑

- **后排的动画要用 `fill: forwards`**：同一个元素先有一段动画、后面再加一段时，后一段如果用 `fill: both`，它「还没开始」时的状态会盖住前一段，前一段等于没播。
- **落点用独立的 `translate` / `scale` 属性**：印痕本身 `rotate: -4deg`，如果把平移写进 `transform`，平移会沿着旋转后的方向走，飞 700px 能偏出 50px。
- **羽化的洞要从负半径开始**：`transparent var(--hole), #000 calc(var(--hole) + 70px)` 在半径 0 时，Logo 周围 70px 已经半透明，一开场就把底下的页面透出来了；`--hole` 要从 `-70px` 起。
- **拆字前先藏起来**：「的开发笔记」要拆成单字才能逐字浮现，拆之前那一刻整行会闪一下；父元素先 `visibility: hidden`，拆出来的字再 `visibility: visible`。
- 字符串拆字用 `Array.from(str)`，不要 `[...str]`（站里的 ESLint 规则会报）；拆之前先 `trim()`，否则模板里的换行和缩进也会被拆成「字」。

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

在线演示：https://zhuoqidev.com/design/demos/stamp-opening/。单文件，存成 .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>落款盖章开场</title>
    <!-- Fonts from Fontsource on jsDelivr (each file is a unicode-range slice, so only the
         characters on the page are fetched). Everything falls back to system faces. -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/long-cang@5.2.8/400.css" />
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/caveat@5.2.8/600.css" />
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/noto-serif-sc@5.3.0/900.css" />
    <style>
      :root {
        --paper: #fbf8f1; --card: #fffdf8; --dot: #d8d0bf; --line: #e6dfd0;
        --ink: #1c2230; --ink-2: #4a5163; --ink-3: #636877; --blue: #2f5bd3; --blue-line: rgba(47, 91, 211, .35);
        --stamp: #c0391f; --hi: #ffe066; --chip: rgba(28, 34, 48, .07); --chip-on: rgba(28, 34, 48, .13);
        --grain-blend: multiply;
        --f-hand: "Long Cang", "Kaiti SC", STKaiti, KaiTi, cursive;
        --f-latin-hand: Caveat, "Comic Sans MS", cursive;
        --f-serif: "Noto Serif SC", "Songti SC", STSong, serif;
        --f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
        --f-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
        /* the two big characters, and the seal (the logo seal is 32px) */
        --H: clamp(84px, 21vw, 190px);
        --S: calc(var(--H) * .59);
        color-scheme: light;
      }
      @media (prefers-color-scheme: dark) {
        :root {
          --paper: #14161b; --card: #1e2129; --dot: #2c3039; --line: #31353f;
          --ink: #ece7dc; --ink-2: #bab6ac; --ink-3: #959aa8; --blue: #8eaaff; --blue-line: rgba(142, 170, 255, .45);
          --hi: #7d6416; --chip: rgba(255, 255, 255, .08); --chip-on: rgba(255, 255, 255, .16);
          --grain-blend: screen;
          color-scheme: dark;
        }
      }
      * { box-sizing: border-box; }
      html, body { margin: 0; height: 100%; }
      body {
        overflow: hidden; color: var(--ink); font: 15px/1.6 var(--f-sans);
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
      }

      /* ---------- the page the opening uncovers (a small mock of the blog's homepage) ---------- */
      .top {
        display: flex; align-items: center; gap: 10px; height: 54px; padding: 0 18px;
        border-bottom: 2px dashed var(--blue-line); background: var(--paper);
      }
      .seal {
        display: grid; place-items: center; width: 32px; height: 32px; flex: none; border-radius: 5px; rotate: -4deg;
        background: var(--stamp); color: #fff8ef; box-shadow: inset 0 0 0 2px rgba(255, 248, 239, .55);
        font: 900 12px/1.05 var(--f-serif); letter-spacing: 1px; writing-mode: vertical-rl;
      }
      .top b { font: 700 16px/1.2 var(--f-serif); }
      .top small { font: 12px var(--f-mono); color: var(--ink-3); }
      main { padding: 28px 22px; display: grid; gap: 18px; max-width: 760px; }
      h1 { margin: 0; font: 400 clamp(30px, 6vw, 46px)/1.15 var(--f-hand); }
      h1 .mark { padding: 0 4px; background: linear-gradient(transparent 58%, var(--hi) 58%, var(--hi) 92%, transparent 92%); }
      .sub { margin: -8px 0 0; font: 600 20px/1.2 var(--f-latin-hand); color: var(--blue); }
      .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; }
      .card { padding: 14px 16px; background: var(--card); border: 1.5px dashed var(--blue-line); border-radius: 12px; }
      .card span { font: 11px var(--f-mono); color: var(--ink-3); letter-spacing: .06em; }
      .card p { margin: 4px 0 0; font: 700 15px/1.45 var(--f-serif); }

      /* ---------- the opening ---------- */
      @property --hole { syntax: "<length>"; inherits: false; initial-value: -70px; }
      .boot {
        display: none; position: fixed; inset: 0; z-index: 10; overflow: hidden;
        --ox: 34px; --oy: 27px; /* replaced with the logo's real centre */
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
        /* the page opens from the logo through a hole with a 70px soft edge; the hole starts at
           -70px so the edge is outside too and nothing shows through before it opens */
        -webkit-mask: radial-gradient(circle at var(--ox) var(--oy), transparent var(--hole), #000 calc(var(--hole) + 70px));
        mask: radial-gradient(circle at var(--ox) var(--oy), transparent var(--hole), #000 calc(var(--hole) + 70px));
      }
      .boot.on { display: block; }
      .stage { position: absolute; inset: 0; display: grid; place-items: center; }
      .sig { position: relative; display: grid; justify-items: start; gap: 4px; translate: calc(var(--S) * -.55) 0; }
      .hanzi { display: block; width: calc(var(--H) * 1.92); height: var(--H); overflow: visible; filter: url(#brush); }
      .hanzi path { fill: none; stroke: var(--ink); stroke-width: 200; stroke-linecap: round; stroke-linejoin: round; }
      .hanzi text { fill: var(--ink); font: 900 860px var(--f-serif); opacity: 0; }
      .line { margin: 0; display: flex; align-items: baseline; gap: 12px; padding-left: calc(var(--H) * .08); }
      .line .name { font: 400 calc(var(--H) * .17)/1.2 var(--f-hand); }
      .line .url { font: 600 calc(var(--H) * .15)/1 var(--f-latin-hand); color: var(--blue); }
      /* hidden until the script splits it into letters, which then fade in one by one */
      .line [data-split] { visibility: hidden; }
      .line span span { display: inline-block; visibility: visible; opacity: 0; }
      .seal-wrap { position: absolute; width: var(--S); height: var(--S); left: calc(100% + var(--S) * .06); top: calc(var(--H) - var(--S) * .78); }
      .seal-wrap > div { position: absolute; inset: 0; opacity: 0; }
      .shadow { border-radius: calc(var(--S) * .16); background: rgba(40, 30, 20, .55); filter: blur(18px); }
      .seal-wrap > .dent { inset: -35%; border-radius: 50%; background: radial-gradient(closest-side, transparent 58%, rgba(120, 90, 60, .22) 72%, transparent 100%); }
      /* the print: the same face as the logo seal, worn by #worn */
      .print {
        display: grid; place-items: center; border-radius: calc(var(--S) * .167); rotate: -4deg;
        background: var(--stamp); color: #fff8ef; box-shadow: inset 0 0 0 calc(var(--S) * .056) rgba(255, 248, 239, .55);
        font: 900 calc(var(--S) * .36)/1.05 var(--f-serif); letter-spacing: calc(var(--S) * .028); writing-mode: vertical-rl;
        filter: url(#worn);
      }
      /* fresh ink shines for a moment, then dries */
      .wet {
        border-radius: calc(var(--S) * .167); rotate: -4deg; mix-blend-mode: soft-light;
        background: linear-gradient(125deg, rgba(255, 255, 255, .55) 0%, transparent 38%, rgba(255, 255, 255, .2) 62%, transparent 80%);
      }
      /* the stone seal seen from above: pale stone, a knob, a red lip of ink at the bottom */
      .stone {
        border-radius: calc(var(--S) * .14);
        background:
          radial-gradient(120% 90% at 25% 15%, rgba(255, 255, 255, .55), transparent 45%),
          radial-gradient(80% 60% at 80% 85%, rgba(120, 70, 30, .25), transparent 60%),
          linear-gradient(150deg, #efe3cb 0%, #dcc6a0 48%, #c3a57a 100%);
        box-shadow:
          inset 0 calc(var(--S) * .02) 0 rgba(255, 255, 255, .7),
          inset 0 calc(var(--S) * -.05) calc(var(--S) * .08) rgba(90, 55, 25, .35),
          0 calc(var(--S) * .035) 0 #a8321b;
      }
      .stone::after {
        content: ""; position: absolute; inset: 34%; border-radius: 50%;
        background: radial-gradient(circle at 40% 35%, #f6ecda, #c8ab80 70%);
        box-shadow: inset 0 -2px 4px rgba(90, 55, 25, .4), 0 2px 3px rgba(90, 55, 25, .25);
      }
      .ink { position: absolute; border-radius: 50%; background: var(--stamp); opacity: 0; }
      /* paper grain and darker corners */
      .grain {
        position: absolute; inset: 0; pointer-events: none; opacity: .55; mix-blend-mode: var(--grain-blend);
        background:
          radial-gradient(ellipse 75% 70% at 50% 48%, transparent 55%, rgba(110, 90, 60, .14)),
          url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .42  0 0 0 0 .36  0 0 0 0 .26  0 0 0 .5 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
      }
      .ripple { position: absolute; left: var(--ox); top: var(--oy); width: 0; height: 0; border-radius: 50%; border: 1.5px solid var(--stamp); opacity: 0; }

      /* ---------- controls (above the opening) ---------- */
      .bar {
        position: fixed; left: 0; right: 0; bottom: 16px; z-index: 20; display: flex; flex-wrap: wrap;
        justify-content: center; gap: 8px; padding: 0 16px; pointer-events: none;
      }
      .bar button, .bar label {
        pointer-events: auto; font: 13px/1 var(--f-sans); color: var(--ink); background: var(--chip);
        border: 0; border-radius: 999px; padding: 9px 14px; cursor: pointer; backdrop-filter: blur(6px);
      }
      .bar button:hover, .bar label:hover { background: var(--chip-on); }
      .bar button:focus-visible, .bar input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
      .bar label { display: inline-flex; align-items: center; gap: 6px; user-select: none; }
      .bar input { margin: 0; accent-color: var(--stamp); }
    </style>
  </head>
  <body>
    <svg width="0" height="0" style="position: absolute" aria-hidden="true">
      <!-- the brush edge: ink spreads a little into the paper -->
      <filter id="brush" x="-5%" y="-5%" width="110%" height="110%">
        <feTurbulence type="fractalNoise" baseFrequency=".035" numOctaves="2" seed="11" result="t" />
        <feDisplacementMap in="SourceGraphic" in2="t" scale="3" xChannelSelector="R" yChannelSelector="G" />
      </filter>
      <!-- a real print: rough edges and a few spots the ink missed -->
      <filter id="worn" x="-10%" y="-10%" width="120%" height="120%">
        <feTurbulence type="fractalNoise" baseFrequency="1.1" numOctaves="2" seed="7" result="grain" />
        <feDisplacementMap in="SourceGraphic" in2="grain" scale="2" xChannelSelector="R" yChannelSelector="G" result="rough" />
        <feTurbulence type="fractalNoise" baseFrequency=".045" numOctaves="2" seed="4" result="blot" />
        <feColorMatrix in="blot" type="matrix" values="0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 -4 3.2" result="holes" />
        <feComposite in="rough" in2="holes" operator="in" />
      </filter>
    </svg>

    <div class="page">
      <header class="top">
        <span class="seal" aria-hidden="true">卓琪</span>
        <b>卓琪的开发笔记</b>
        <small>zhuoqidev.com</small>
      </header>
      <main>
        <h1>把 Agent 做进<span class="mark">真实产品</span></h1>
        <p class="sub">notes from shipping agents to production</p>
        <div class="cards">
          <div class="card"><span>最新 · 10-24</span><p>买中转 API 之前先验货</p></div>
          <div class="card"><span>选型 · 10-18</span><p>Mac 到底适不适合跑大模型</p></div>
          <div class="card"><span>工程实践 · 05-16</span><p>为什么我们从 Celery 迁移到 Temporal</p></div>
        </div>
      </main>
    </div>

    <div class="boot" aria-hidden="true">
      <div class="stage">
        <div class="sig">
          <svg class="hanzi" viewBox="0 0 1966 1024"></svg>
          <p class="line"><span class="name" data-split>的开发笔记</span><span class="url" data-split>zhuoqidev.com</span></p>
          <div class="seal-wrap">
            <div class="dent"></div>
            <div class="print">卓琪</div>
            <div class="wet"></div>
            <div class="shadow"></div>
            <div class="stone"></div>
          </div>
        </div>
      </div>
      <div class="grain"></div>
      <div class="ripple"></div>
    </div>

    <div class="bar">
      <button type="button" data-play>↻ 再播一次</button>
      <label><input type="checkbox" data-slow /> 慢放 ¼×</label>
    </div>

    <script>
      // Timeline (ms):  250 卓琪 in stroke order (1.55 s) · 1750 the line under it · 2050 the stone
      // hovers and comes down · 2500 it lands, ink spatters, the print appears · 2560 the stone lifts,
      // the wet print dries · 3150 the print flies onto the logo · 3720 the page opens from the logo · 4380 done.
      // ?t=2700 freezes the opening at that moment (before 3150); with reduced motion it waits for the button.

      // Stroke outlines and centre lines from Make Me a Hanzi, published as hanzi-writer-data
      // (Arphic Public License; the data is fetched, not copied into this file).
      const DATA = "https://cdn.jsdelivr.net/npm/hanzi-writer-data@2.0.1/";
      const SVG = "http://www.w3.org/2000/svg";
      const SKIP_ON = ["pointerdown", "keydown", "wheel", "touchstart"];
      // ink spots around the seal: x, y, size as fractions of the seal, and a height factor
      const SPOTS = [[-0.34, -0.22, 0.07, 1], [1.18, -0.12, 0.045, 0.7], [1.1, 0.96, 0.06, 1.3], [-0.2, 1.12, 0.035, 1], [1.3, 0.42, 0.03, 0.8], [0.5, -0.3, 0.03, 1.4], [-0.42, 0.6, 0.04, 0.9]];

      const boot = document.querySelector(".boot");
      const $ = (sel) => boot.querySelector(sel);
      const glyphs = Promise.all(["卓", "琪"].map((ch) => fetch(DATA + encodeURIComponent(ch) + ".json").then((r) => r.json()))).catch(() => null);
      const original = boot.innerHTML;
      let stop = () => {};

      // Each stroke outline is a clip path; a thick line drawn along its centre line inside it
      // reveals the stroke the way a brush would. Without the data, the two characters fade in as text.
      function strokes(svg, data) {
        if (!data) {
          const text = svg.appendChild(document.createElementNS(SVG, "text"));
          Object.assign(text, { textContent: "卓琪" });
          text.setAttribute("x", "0");
          text.setAttribute("y", "860");
          return [];
        }
        const defs = svg.appendChild(document.createElementNS(SVG, "defs"));
        const out = [];
        data.forEach((glyph, gi) => {
          const g = svg.appendChild(document.createElementNS(SVG, "g"));
          g.setAttribute("transform", `translate(${gi * 942} 900) scale(1 -1)`);
          glyph.strokes.forEach((outline, i) => {
            const clip = defs.appendChild(document.createElementNS(SVG, "clipPath"));
            clip.id = `s${gi}-${i}`;
            clip.appendChild(document.createElementNS(SVG, "path")).setAttribute("d", outline);
            const pen = g.appendChild(document.createElementNS(SVG, "path"));
            pen.setAttribute("d", "M" + glyph.medians[i].map((p) => p.join(" ")).join(" L"));
            pen.setAttribute("clip-path", `url(#${clip.id})`);
            const len = pen.getTotalLength() + 200;
            pen.style.strokeDasharray = `${len} ${len}`;
            pen.style.strokeDashoffset = len;
            out.push({ pen, len, glyph: gi });
          });
        });
        return out;
      }

      async function play({ rate = 1, freezeAt = null } = {}) {
        stop();
        boot.innerHTML = original;
        boot.style.removeProperty("--hole");
        boot.getAnimations().forEach((a) => a.cancel());
        boot.classList.add("on"); // the paper goes down first, then the stroke data is awaited
        const data = await glyphs;
        const stage = $(".stage"), sig = $(".sig"), seal = $(".seal-wrap"), stone = $(".stone"), shadow = $(".shadow");
        const print = $(".print"), wet = $(".wet"), dent = $(".dent"), line = $(".line"), ripple = $(".ripple");
        const svg = $(".hanzi");
        const logo = document.querySelector(".top .seal");

        const anims = [], timers = [];
        // later animations on an element that already has one use fill "forwards", or their waiting
        // state would override the earlier one while it runs
        const A = (el, kf, o) => {
          const a = el.animate(kf, { fill: "both", ...o });
          a.playbackRate = rate;
          anims.push(a);
          return a;
        };
        const at = (ms, fn) => timers.push(setTimeout(fn, ms / rate));
        let done = false;
        const finish = () => {
          if (done) return;
          done = true;
          timers.forEach(clearTimeout);
          SKIP_ON.forEach((t) => removeEventListener(t, skip));
          boot.classList.remove("on");
          anims.forEach((a) => a.cancel());
        };
        function skip(e) {
          if (e.target.closest?.(".bar")) return;
          SKIP_ON.forEach((t) => removeEventListener(t, skip));
          boot.animate({ opacity: [1, 0] }, { duration: 220 }).finished.then(finish, finish);
        }
        stop = finish;
        if (freezeAt === null) SKIP_ON.forEach((t) => addEventListener(t, skip, { passive: true }));

        const r0 = logo.getBoundingClientRect();
        const tx = r0.left + r0.width / 2, ty = r0.top + r0.height / 2;
        boot.style.setProperty("--ox", `${tx}px`);
        boot.style.setProperty("--oy", `${ty}px`);
        const pens = strokes(svg, data);
        const chars = [...line.querySelectorAll("[data-split]")].flatMap((el) => {
          el.replaceChildren(...Array.from(el.textContent.trim(), (c) => Object.assign(document.createElement("span"), { textContent: c })));
          return [...el.children];
        });
        const S = seal.offsetWidth;
        const inks = SPOTS.map(([x, y, d, r]) => {
          const i = seal.appendChild(document.createElement("i"));
          i.className = "ink";
          Object.assign(i.style, { left: `${x * S}px`, top: `${y * S}px`, width: `${d * S}px`, height: `${d * S * r}px` });
          return i;
        });

        // camera: the scene drifts 3.5% closer while it plays
        const push = A(stage, { scale: [1, 1.035] }, { duration: 3400, easing: "cubic-bezier(.3,.1,.3,1)" });

        // 1) 卓琪 in stroke order: 1.55 s shared by length, 22 ms between strokes, 120 ms between the two
        if (pens.length) {
          const budget = 1550 - 22 * (pens.length - 1) - 120;
          const sum = pens.reduce((n, s) => n + s.len + 260, 0);
          let t = 250;
          pens.forEach((s, i) => {
            if (i && s.glyph !== pens[i - 1].glyph) t += 120;
            const duration = ((s.len + 260) / sum) * budget;
            A(s.pen, { strokeDashoffset: [s.len, 0] }, { delay: t, duration, easing: "cubic-bezier(.4,.05,.5,.95)" });
            t += duration + 22;
          });
        } else {
          A(svg.querySelector("text"), { opacity: [0, 1] }, { delay: 250, duration: 1200 });
        }

        // 2) the line under it, letter by letter out of a blur
        chars.forEach((c, i) => A(c, [{ opacity: 0, transform: "translateY(10px)", filter: "blur(6px)" }, { opacity: 1, transform: "none", filter: "blur(0)" }], { delay: 1750 + i * 38, duration: 420, easing: "cubic-bezier(.2,.8,.2,1)" }));

        // 3) the stone comes down from above, hovers, lifts a touch, presses
        const hit = 2500;
        const down = { delay: 2050, duration: hit - 2050, easing: "cubic-bezier(.55,0,.75,.2)" };
        A(stone, [
          { opacity: 0, transform: "translate(-6%, -42%) scale(1.55) rotate(-16deg)" },
          { opacity: 1, transform: "translate(-3%, -22%) scale(1.3) rotate(-8deg)", offset: 0.55 },
          { opacity: 1, transform: "translate(-3%, -26%) scale(1.34) rotate(-7deg)", offset: 0.78 },
          { opacity: 1, transform: "translate(0, 0) scale(.97) rotate(-4deg)" },
        ], down);
        A(shadow, [
          { opacity: 0, transform: "translate(34%, 46%) scale(1.25)", filter: "blur(26px)" },
          { opacity: 0.28, transform: "translate(22%, 30%) scale(1.15)", filter: "blur(20px)", offset: 0.7 },
          { opacity: 0.55, transform: "translate(2%, 4%) scale(1)", filter: "blur(5px)" },
        ], down);

        // 4) the landing: the paper dips, a pressure ring, spatter, the print
        A(sig, [{ transform: "none" }, { transform: "translateY(3px)" }, { transform: "none" }], { delay: hit, duration: 220, easing: "ease-out", fill: "none" });
        A(dent, [{ opacity: 0, scale: 0.7 }, { opacity: 1, scale: 1, offset: 0.25 }, { opacity: 0, scale: 1.25 }], { delay: hit, duration: 700, easing: "ease-out" });
        A(print, { opacity: [0, 1] }, { delay: hit, duration: 60 });
        A(wet, [{ opacity: 0 }, { opacity: 1, offset: 0.1 }, { opacity: 0 }], { delay: hit + 150, duration: 1000, easing: "ease-in" });
        inks.forEach((el, i) => A(el, [{ opacity: 0, scale: 0 }, { opacity: 0.85, scale: 1.15, offset: 0.6 }, { opacity: 0.85, scale: 1 }], { delay: hit + 10 + i * 12, duration: 200, easing: "cubic-bezier(.2,.9,.3,1)" }));

        // 5) the stone lifts away; its shadow spreads and fades
        A(stone, [
          { transform: "translate(0, 0) scale(.97) rotate(-4deg)", opacity: 1, filter: "blur(0)" },
          { transform: "translate(0, 0) scale(.985) rotate(-4deg)", opacity: 1, filter: "blur(0)", offset: 0.3 },
          { transform: "translate(3%, -24%) scale(1.3) rotate(-2deg)", opacity: 0, filter: "blur(6px)", offset: 0.85 },
          { transform: "translate(4%, -30%) scale(1.4) rotate(-1deg)", opacity: 0, filter: "blur(8px)" },
        ], { delay: hit + 60, duration: 460, easing: "cubic-bezier(.3,0,.2,1)", fill: "forwards" });
        A(shadow, [
          { opacity: 0.55, transform: "translate(2%, 4%) scale(1)", filter: "blur(5px)" },
          { opacity: 0, transform: "translate(30%, 44%) scale(1.3)", filter: "blur(26px)" },
        ], { delay: hit + 230, duration: 400, easing: "ease-out", fill: "forwards" });

        if (freezeAt !== null) {
          anims.forEach((a) => { a.pause(); a.currentTime = freezeAt; });
          return;
        }

        // 6) the rest fades; the print flies along an arc onto the logo
        const fly = 3150;
        for (const el of [svg, line, ...inks]) A(el, [{ opacity: 1, filter: "blur(0)" }, { opacity: 0, filter: "blur(4px)" }], { delay: fly - 30, duration: 320, easing: "ease-in", fill: "forwards" });
        at(fly, () => {
          push.pause(); // the drifting camera stops so the landing spot holds still
          const k = stage.getBoundingClientRect().width / stage.offsetWidth;
          const r = print.getBoundingClientRect();
          const dx = (tx - (r.left + r.width / 2)) / k, dy = (ty - (r.top + r.height / 2)) / k;
          const s = logo.offsetWidth / (S * k);
          A(print, [
            { translate: "0px 0px", scale: 1 },
            { translate: `${dx * 0.45}px ${dy * 0.45 - 70 / k}px`, scale: (1 + s) / 2, offset: 0.5 },
            { translate: `${dx}px ${dy}px`, scale: s },
          ], { duration: 520, easing: "cubic-bezier(.6,0,.2,1)", fill: "forwards" });
        });

        // 7) the page opens from the logo, a red ripple riding the soft edge
        const open = 3720, spread = 640;
        const R = Math.hypot(Math.max(tx, innerWidth - tx), Math.max(ty, innerHeight - ty)) + 120;
        const opening = { delay: open, duration: spread, easing: "cubic-bezier(.55,0,.3,1)" };
        A(boot, { "--hole": ["-70px", `${R}px`] }, opening);
        A(ripple, [
          { opacity: 0.85, width: "0px", height: "0px", margin: "0px" },
          { opacity: 0, width: `${2 * R + 70}px`, height: `${2 * R + 70}px`, margin: `${-R - 35}px` },
        ], opening);
        at(open + spread + 20, finish);
      }

      const slow = document.querySelector("[data-slow]");
      document.querySelector("[data-play]").addEventListener("click", () => play({ rate: slow.checked ? 0.25 : 1 }));
      const t = new URLSearchParams(location.search).get("t");
      if (t !== null) play({ freezeAt: Math.min(Number(t) || 0, 3100) });
      else if (!matchMedia("(prefers-reduced-motion: reduce)").matches) play();
    </script>
  </body>
</html>
```
