作品展示页动画实现原理

最初做作品展示页时,我把它理解成“让五张卡片依次动起来”。后来页面经历了几轮调整,真正变化的不是多加了几个位移或淡入,而是实现的重心从“堆动画效果”转成了“维护一条可控制、可验证的滚动时间轴”。

现在的页面仍然没有引入重型动画库。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 过渡,但还没有为整段分镜提供专门的静态展示模式。由于作品链接不依赖动画层,内容不会丢失;下一步更理想的做法是让该媒体查询直接展示首帧或静态作品列表,并跳过滚动平滑与视频播放。

验证不是“看起来能动”

每次调整分镜后,我会从三个方向检查:

  1. 节奏:慢慢滚动时,第五张卡片和结尾文案是否都有可阅读的停留;快速滚动后画面是否平稳追上目标,而不是闪到终点。
  2. 图层:卡片退场后是否确实为 0;黑场是否干净;快门再次打开时,结尾场景是否已经准备好而没有白屏或裸露的中间状态。
  3. 环境:在展示区内改变窗口尺寸或旋转手机,进度是否保持;禁用或拒绝视频自动播放时,结尾是否仍可理解;键盘访问时,作品链接是否只在普通列表中出现一次。

内容改动完成后,仓库内使用以下命令检查:

结语

这页最终留下的不是一套“滚动动画技巧”,而是一种更适合维护的拆分方式:

  • CSS 管舞台、裁切和图层;
  • 滚动事件只更新目标;
  • rAF 负责在帧间平滑渲染;
  • 分镜函数把连续进度切成可读的区间;
  • 资源预热和可访问内容各自有独立边界。

当动画需要继续加镜头、换素材或调整节奏时,最重要的不是继续往页面里塞效果,而是先问清楚:这段变化处在哪个区间、它和前后镜头如何交接、内容是否仍有不依赖动画的路径。把这些问题写进结构之后,滚动才会真正成为叙事,而不是一串碰巧同时发生的视觉效果。

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论