滚动动效 是做什么的?
一个由滚动位置驱动的 Canvas 时间线:一个固定的舞台,完全由进度决定状态的晶片,以及绘制层永不触碰的说明文字。此方法驱动了 ImuTechz 首页的故事转盘、研发转盘和创始人路径影片。它还包含了 assets/demo.html 中的五晶片演示。
整个技术可以用一句话概括:滚动位置是唯一的时钟:将其映射到 p ∈ [0, 1],并让每个像素、说明文字和旋转角度都是 p 的函数。
理想效果
- 像视频一样逐帧播放。 向前滚动,影片播放;向后滚动,影片逐帧倒放。中途停止时,停留的画面有意义。
- 从不劫持页面。 滚轮、触控板、触摸和键盘的操作方式与页面上的其他任何地方完全一致。动效章节只是变得很高。
- 文字始终可读。 说明文字是真实的 DOM 文本,位于卡片上。绘制内容在说明文字留下的空间中展开,Canvas 会围绕说明文字裁剪作为安全网。
- 是控制器,而非展示品。 故事转盘的各个面都是链接。其导轨、箭头按钮、方向键和鼠标拖拽都通过滚动页面来定位,因此滚动和转盘永远不会不一致。
- 空闲页面零消耗。 一旦 p 稳定或章节离开视口,不再运行 requestAnimationFrame。
- 尊重减少动效设置。 无平滑动画,无转盘中间滚动,无说明文字入场动画。所有内容仍然存在。
- 在 360 到 1920 px 宽、浅色和深色模式、DPR 1 和 2 下均表现良好。
硬性规则
- p 来自滚动位置,绝不来自时钟。 不要在晶片内部使用
performance.now()、Date、CSS 动画或setInterval。允许平滑处理(向目标 p 进行线性插值),因为它会收敛到相同的画面。 - 晶片是纯函数
draw(ctx, t, env)。它不跨帧保持状态,且从不调用Math.random();应使用种子化的hash(i)代替。相同的 (t, env) 必须绘制相同的像素。 - 使用 CSS 而非 JS 进行固定。 使用高大的轨道(
position: relative; height: N×vh)容纳一个position: sticky; top: 0; height: 100svh的舞台。不要对舞台设置position: fixed,不要锁定 body,也不要在轨道的祖先元素上设置overflow: hidden|auto|clip。 - 永不捕获输入。 不要在滚轮或触摸事件上使用
preventDefault,不要在 html 或 body 上使用overflow: hidden,也不要在轨道上使用滚动吸附。只有获得焦点的转盘可以处理方向键,且它通过滚动页面来实现。 - 通过滚动定位。 每个按钮、导轨项、键和锚点都改变
window.scrollY。绝不直接设置 p 或旋转角度。对于不得产生动画的程序化跳跃,使用behavior: "instant",因为"auto"会遵循页面的 CSSscroll-behavior: smooth。 - 文字存在于 DOM 中。 说明文字、标题和链接是 HTML。Canvas 文本仅限于装饰性标签,如年份、坐标轴刻度和步骤名称。
- 每帧测量说明文字并避开它们。 每个说明文字和 HUD 元素都是禁区(
data-keep)。晶片在env.free内布局,引擎使用奇偶裁剪法裁剪禁区。 - 永不同时显示两个说明文字。 说明文字窗口位于其晶片内部,每个边界处留有间隙,晶片在该间隙内交叉淡入淡出。
- 空闲时停止循环。 仅当章节接近视口时(IntersectionObserver),在滚动或调整大小时启动 rAF,并在 p 等于目标时停止。
- Canvas 尺寸设置为舞台尺寸乘以
min(devicePixelRatio, 2)。 每帧调用一次setTransform(dpr, …)并每帧清除。 - 颜色来自 CSS 自定义属性。 不要在晶片中硬编码浅色模式调色板。在
prefers-color-scheme变化时重新读取调色板。 - 尊重
prefers-reduced-motion: reduce。 不使用平滑处理,让转盘面对面对齐,关闭说明文字入场动画。画面仍然跟随滚动,因为由用户驱动。
构建顺序
按此顺序进行。每一步在下一步之前都可测试。
- 内容优先。 编写章节:每个章节包括一个引子、一个标题、一到两句话和一个可选的链接。选择晶片数量(3 到 7 个),并为每个晶片分配大约 80 到 100 svh 的滚动高度。
- 轨道和舞台。 添加包含固定 100svh 舞台的高大章节,并检查它无需任何 JS 即可固定(参见 layout-and-captions)。
- 擦洗引擎。 连接
ScrollMotion.scrub或film(参见 engine)并记录 p 值。检查它在固定开始和结束时是否分别为 0 和 1,并且可以双向移动。 - DOM 中的说明文字和 HUD。 按断点放置它们,标记为
data-keep,并让引擎根据 p 驱动其不透明度。 - 晶片,一次一个。 在
env.free内编写draw(ctx, t, env)(参见 drawing),并在编写下一个之前来回擦洗它。 - 动效语法。 添加入场、保持和退场包络线、交错、停留和交叉淡入淡出(参见 motion-grammar)。
- 交互性(如果是转盘)。 添加面链接、导轨、上一个和下一个按钮、键盘和拖拽,全部通过滚动定位。
- 性能和无障碍检查。 涵盖空闲循环、DPR、减少动效、焦点顺序、屏幕阅读器文字和深色模式(参见 performance-and-a11y)。
- 验证 在 360、390、414、768、1024、1280、1440 和 1920 宽度下使用无头脚本(参见 verification)。在所有检查通过之前不要宣布完成。
决策清单
Canvas 影片还是 DOM/CSS 变换?
- 许多微小运动部件、绘制的线条、粒子或图表 → Canvas 影片 (
ScrollMotion.film)。 - 几张卡片、图像或文本面板 → 由
scrub驱动的 DOM 变换(转盘就是一种)。 - 真实的 3D 网格或着色器 → 此技能不足。使用 WebGL,但保留规则 1 到 5 和 9 到 12。
固定模式还是视图模式?
- 章节应在播放故事时保持屏幕 →
mode: "pin"(高大轨道和固定舞台)。 - 效果随内容滚动而移动,如跑马灯或视差 →
mode: "view",无需固定。
轨道应该多高?
- 对于影片使用
n × 80–100svh + 100svh,对于转盘使用n × 70vh + 100vh。如果测试读者反馈“太快了”,增加高度。绝不要通过捕获滚轮来减速。
说明文字放在哪里?
- 桌面端(≥ 760 px):放在绘制旁边(左侧或右侧卡片)或作为宽底部卡片。手机端:始终作为底部工作表,绘制内容在其上方。
- 如果有固定站点头部,设置
--sm-nav-h以使 HUD 和转盘避开它。
是否需要转盘?
- 当章节是用户可能想要跳转到的事物(页面、产品、章节)时使用转盘。否则仅使用影片。
故障模式与修复
| 症状 | 原因 | 修复 |
|---|---|---|
| 舞台随滚动离开而非固定 | 祖先元素有 overflow: hidden/auto/clip,或舞台没有设置 top | 移除溢出(必要时在 body 上使用 overflow-x: clip)并设置 top: 0 |
| 动画仅向前播放,或向上滚动时跳跃 | 跨帧累积状态,或使用了时钟或 Math.random | 使晶片成为 t 的纯函数;使用 hash(i) 实现随机性 |
| 绘制内容位于说明文字下方 | 晶片在完整舞台上布局 | 在 env.free 中布局;在说明文字上保留 data-keep;让引擎裁剪 |
| 说明文字框在交叠过程中测量位置错误 | 使用了 getBoundingClientRect(包含变换) | 测量布局框(offsetLeft/Top/Width/Height,参见 boxIn) |
| 两个说明文字在边界处重叠 | 说明文字窗口相接 | 在 1 − cross 时淡出说明文字,在 cross 时淡入;晶片在间隙中交叉淡入淡出 |
| 空闲页面上风扇旋转 | rAF 循环从未停止 | 当 cur === target 时停止;通过 IntersectionObserver 控制 |
| 手机画布模糊 / 4K 上变慢 | Canvas 使用 CSS 大小,或 DPR 未设上限 | 使用 width = W × min(dpr, 2) 和 setTransform(dpr, 0, 0, dpr, 0, 0) |
| 点击导轨后转盘和页面不一致 | 控件直接设置了旋转角度 | 每个控件都调用 scrollToProgress;旋转角度始终来自 p |
| 拖拽抖动然后弹回 | 页面有 scroll-behavior: smooth,导致 scrollTo(x, y) 产生动画 | 拖拽时使用 scrollTo({ top, behavior: "instant" }) |
| 拖拽后点击面打开了链接 | 结束拖拽的点击事件触发了 | 在拖拽移动超过 4 px 后,吞掉一个捕获阶段的点击事件 |
| 移动端 100vh 在 URL 栏隐藏时跳动 | 舞台上使用了 vh | 舞台使用 100svh;使用舞台自身高度计算 p |
| 深色模式显示浅色模式绘制内容 | 硬编码颜色 | 通过 readPalette 读取 --sm-* 令牌;在方案改变时重新读取 |
| 转盘背面透出 | 缺少 backface-visibility: hidden | 连同 -webkit- 前缀在每个面上设置 |
| 转盘面缩略图尺寸错误 | 使用了经过 3D 变换的面的投影矩形 | 使用 clientWidth/clientHeight 确定大小 |
文件
- references/engine.md: p、擦洗循环、
film()、定位、React 钩子。 - references/drawing.md: 晶片契约、Canvas 设置、路径、弹出、粒子和调色板。
- references/layout-and-captions.md: 轨道和舞台 CSS、禁区、自由矩形、裁剪、转盘布局。
- references/motion-grammar.md: 包络线、交错、停留、交叉淡入淡出、节奏和缓动。
- references/performance-and-a11y.md: 空闲循环、布局成本、减少动效、键盘、焦点和屏幕阅读器。
- references/verification.md: 八种宽度、无头 Brave/Chrome 脚本和手动检查。
- assets/demo.html: 零依赖,双击即可运行。包含五个晶片和故事转盘。
- assets/scroll-motion.js: 引擎(
scrub、film、drum、scrollToProgress及辅助函数),UMD。 - assets/reels.js: 五个示例晶片(轨道、路径、堆叠、波浪、闭合)和用于缩略图的
still()。 - assets/scroll-motion.css: 影片和转盘样式,浅色和深色令牌。
- assets/ScrollFilm.jsx: 影片的 React 封装,以及
useScrub钩子。 - assets/StoryDrum.jsx: React 故事转盘,以及自转的
DrumPreview。 assets/preview-orbit.jpg、assets/preview-path.jpg、assets/preview-close.jpg: 演示在 1440 × 900 下的截图。
从演示开始:打开它,查看源码,替换内容。文件底部的连接代码大约有 60 行。