作品展示页动画实现原理
最初做作品展示页时,我把它理解成“让五张卡片依次动起来”。后来页面经历了几轮调整,真正变化的不是多加了几个位移或淡入,而是实现的重心从“堆动画效果”转成了“维护一条可控制、可验证的滚动时间轴”。
现在的页面仍然没有引入重型动画库。Astro 负责结构和资源声明,CSS 负责固定舞台、裁切和图层关系,原生 TypeScript 只做一件事:把浏览器滚动位置转换为稳定的分镜状态。卡片、黑场快门、角色、视频和结尾文案都消费同一个进度值,但它们不再直接绑定原始滚动事件。
这篇文章记录作品展示页的实现思路。文中的区间、资源策略和代码片段以当时的实现为准,不再使用早期的 760vh / 680vh 时间轴或“滚一下就立刻改样式”的写法。
目标不是播放,而是让读者控制节奏
作品展示区有几个约束,它们决定了这不是一组普通的 CSS 入场动画:
- 读者慢慢滚动时,每一张作品卡片都要有足够的停留时间;停下来时,画面也应停住。
- 快速滚动、触摸板惯性和不同刷新率不应让画面突然跳帧。
- 转场要有连续的图层关系,不能让黑场、角色和下一幕像三次互不相干的淡入。
- 首次进入展示区时,图片解码不能集中阻塞主线程。
- 动画不是作品信息的唯一载体;键盘和辅助技术用户仍应能在普通作品列表中访问每个链接。
换句话说,这一页的输入不是“经过了多少秒”,而是“读者在叙事中的位置”。页面滚动位置只是输入信号,真正被渲染的是一个受控的 0 ~ 1 进度。
固定舞台:用滚动距离换取阅读时间
展示区使用“长容器 + sticky 舞台”的结构。长容器提供滚动距离,舞台始终固定在视口内,所有镜头都在这个舞台上完成。
当前 CSS 使用的是桌面端 850vh、窄屏端 760vh,而不是早期更短的数值:
展开收起
这里的 850vh 不是动画持续时间,更像是时间轴的可用长度。真正可用的滚动距离不是容器高度本身,而是容器高度减去视口高度:
这样做有两个细节。
第一,showcaseStart 在测量时写成相对文档的绝对位置,而不是每一帧都重新读取 getBoundingClientRect()。滚动路径中只需计算一次减法和一次夹取,避免不必要的布局读取。
第二,舞台高度选用 100svh。手机浏览器地址栏展开和收起时,svh 比传统 vh 更稳定;同时在 resize 时重新测量,并立即同步渲染进度,避免视口变化后动画从旧位置缓慢追赶到新位置。
移动端并没有简单地把故事压缩得更快。当前实现缩短了总行程,并隐藏卡片中的长说明文字;保留编号和标题,让每个镜头仍有可理解的停留。
不在 scroll 回调里直接渲染
最容易写出的版本,是在每次 scroll 事件中读取进度并修改所有元素的 style。它在鼠标滚轮上似乎可行,但触摸板惯性、主线程短暂停顿和高刷新率设备会让同一段动画显得忽快忽慢。
当前实现把“用户想去哪里”和“当前画面画到哪里”分开:
滚动事件只更新目标值,并且以被动监听注册:
实际渲染发生在 requestAnimationFrame 中。这里没有使用固定比例的 progress += 0.1,而是使用基于时间差的指数平滑:
展开收起
blend = 1 - exp(-dt / duration) 的好处是刷新率无关:在 60Hz 和 120Hz 屏幕上,画面趋近目标的速度一致。elapsed 被限制到 64ms,标签页从后台恢复时也不会因一个异常大的时间差直接跳到终点。进度足够接近后停止申请下一帧,减少静止状态下的无效工作。
渲染函数内部还有一层更细的保护:当新旧进度相差小于 0.00001 时,不再重写样式。这一层在慢速滚动或停住后很有价值,因为页面的图层比普通卡片页多得多。
phaseAt:把连续进度切成可维护的分镜
所有镜头都从同一个 progress 出发,但每个镜头只关心属于自己的时间窗口。为此实现了一个“局部阶段函数”:
phaseAt(progress, start, end) 会先把任意区间归一化到 0 ~ 1,再套一层 smoothstep。它有三个稳定的状态:开始前为 0,区间内平滑变化,结束后为 1。这样每段动画不需要知道页面总高度,也不用自己处理越界。
当前时间轴大致如下:
| 分镜 | 进度区间 | 目的 |
|---|---|---|
| 引导文案淡出 | 0.00 - 约 0.21 | 让读者从引导进入作品卡片 |
| 五张卡片入场 | 第 i 张为 0.04 + 0.055i 到 0.16 + 0.055i | 逐张落位,最后一张不被前几张挤压 |
| 卡片文字入场 | 第 i 张为 0.16 + 0.05i 到 0.25 + 0.05i | 图片先建立画面,再交代标题 |
| 卡片退场 | 0.60 - 0.72 | 统一清空,不留下半透明边框 |
| 黑场与角色 | 0.63 - 0.97 | 把卡片阶段交给快门转场 |
| 上下快门合拢 / 展开 | 0.72 - 0.84 / 0.91 - 0.97 | 形成完整的一次闭合与打开 |
| 结尾场景 | 0.88 - 1.00 | 在转场遮挡下预热,再分层揭示 |
表格里的数字不是“魔法常量”,而是对叙事节奏的显式描述。它们集中在渲染函数里,调整某一幕时只需要移动相邻区间,不会牵连一堆独立的 @keyframes。
卡片为什么要同时算透明度和几何退场
五张作品卡片由数据数组生成,动画代码只使用索引。入场和退场使用同一组局部进度:
入场时,奇偶卡片从相反方向进入;退场时,它们微微散开、缩小并旋转。更重要的是,透明度使用 inProgress * (1 - dissolve),所以退场结束时一定回到 0。
这个约束是有原因的。早期版本曾保留少量半透明残影,卡片边框会在纯黑转场和窗景上变成一层浅灰横带。对于这种需要明确切镜的页面,残影不是氛围,而是上一幕没有离开干净的证据。
卡片文字单独安排了入场和退场,避免与图片一起仓促消失:
这让“图像抵达”和“文字可阅读”成为两个独立的节拍。设计上真正需要保证的不是卡片出现得多炫,而是最后一张卡片完全出现后,读者仍有机会理解它。
快门转场不是淡出,而是一段可交接的场景
卡片退场后,展示区进入黑场。这里的核心不是给画面叠一层黑色,而是明确每一层的职责:
| 图层 | 作用 |
|---|---|
| 黑色背景 | 切断前一段丰富的卡片画面,给下一幕提供干净底色 |
| 上下两条横幅 | 从左右移动,完成快门的合拢和再次展开 |
| 前景角色 | 在黑场中连接卡片阶段与结尾场景 |
| 两个大字 | 只在快门闭合后的短暂读秒出现 |
| 结尾场景 | 先在黑场后方完成准备,再在遮挡结束后被看见 |
上下横幅不是只进不出。它们先从画面外移动到中间,在角色和文字退出时回到两侧:
结尾场景则从 0.88 就开始在快门后方进入:整体场景先渐显,视频和中景在 0.90 - 0.97 进入,前景角色到 0.94 - 1.00 才落位。这个重叠不是浪费,而是为了让读者先看到“场景已在”,再感受到前、中、后景的空间关系。
视频只在进度超过一个很小的阈值后尝试播放,并忽略被浏览器自动播放策略拒绝的结果:
visibility 会在图层完全不可见时关闭其可见性;视频仍然带有 muted、loop、playsinline 和 preload=“auto”。即使视频不能自动播放,静态中景和前景仍会完整呈现,结尾不会变成空白。
资源预热:不要把所有图片同时 decode
展示区一次会用到卡片、快门和结尾场景的多张图片。如果在首次进入页面时对所有图片立即调用 decode(),图片解码可能和首段滚动竞争主线程,反而让第一幕出现卡顿。
当前策略按两个图片为一组预热,每完成一组就让出一个动画帧:
waitForImage() 同时监听 load 和 error,单张资源失败不会把整条预热链卡住。这个策略不追求“最早把所有内容解码完”,而是控制一次被调度到主线程的工作量,让首页和第一段滚动仍保持响应。
动画层不承担链接职责
展示区不承担项目链接职责:卡片列表和快门转场容器标记为 aria-hidden,结尾场景中的图片使用空的 alt。真正可点击、带标题和描述的项目内容在下面的普通作品网格中:
这避免了同一批作品被屏幕阅读器重复朗读,也让动效失效、被跳过或尚未加载时,内容仍有一条稳定的阅读路径。首屏的“向下看作品”入口指向 #showcase,保证想体验叙事的读者进入动画开头;而普通网格则负责承载可访问的项目导航。
这里还有一个应继续完善的点:当前的 prefers-reduced-motion 已经关闭了常规内容的 reveal 过渡,但还没有为整段分镜提供专门的静态展示模式。由于作品链接不依赖动画层,内容不会丢失;下一步更理想的做法是让该媒体查询直接展示首帧或静态作品列表,并跳过滚动平滑与视频播放。
验证不是“看起来能动”
每次调整分镜后,我会从三个方向检查:
- 节奏:慢慢滚动时,第五张卡片和结尾文案是否都有可阅读的停留;快速滚动后画面是否平稳追上目标,而不是闪到终点。
- 图层:卡片退场后是否确实为 0;黑场是否干净;快门再次打开时,结尾场景是否已经准备好而没有白屏或裸露的中间状态。
- 环境:在展示区内改变窗口尺寸或旋转手机,进度是否保持;禁用或拒绝视频自动播放时,结尾是否仍可理解;键盘访问时,作品链接是否只在普通列表中出现一次。
内容改动完成后,仓库内使用以下命令检查:
结语
这页最终留下的不是一套“滚动动画技巧”,而是一种更适合维护的拆分方式:
- CSS 管舞台、裁切和图层;
- 滚动事件只更新目标;
- rAF 负责在帧间平滑渲染;
- 分镜函数把连续进度切成可读的区间;
- 资源预热和可访问内容各自有独立边界。
当动画需要继续加镜头、换素材或调整节奏时,最重要的不是继续往页面里塞效果,而是先问清楚:这段变化处在哪个区间、它和前后镜头如何交接、内容是否仍有不依赖动画的路径。把这些问题写进结构之后,滚动才会真正成为叙事,而不是一串碰巧同时发生的视觉效果。