作品展示页动画实现原理

最初做作品展示页时,我把它理解成“让五张卡片依次动起来”。后来页面经历了几轮调整,真正变化的不是多加了几个位移或淡入,而是实现的重心从“堆动画效果”转成了“维护一条可控制、可验证的滚动时间轴”。
现在的页面仍然没有引入重型动画库。Astro 负责结构和资源声明,CSS 负责固定舞台、裁切和图层关系,原生 TypeScript 只做一件事:把浏览器滚动位置转换为稳定的分镜状态。卡片、黑场快门、角色、视频和结尾文案都消费同一个进度值,但它们不再直接绑定原始滚动事件。
这篇文章对应当前的 src/pages/portfolio/index.astro。文中的区间、资源策略和代码片段都以现有实现为准,不再使用早期的 760vh / 680vh 时间轴或“滚一下就立刻改样式”的写法。
目标不是播放,而是让读者控制节奏
作品展示区有几个约束,它们决定了这不是一组普通的 CSS 入场动画:
- 读者慢慢滚动时,每一张作品卡片都要有足够的停留时间;停下来时,画面也应停住。
- 快速滚动、触摸板惯性和不同刷新率不应让画面突然跳帧。
- 转场要有连续的图层关系,不能让黑场、角色和下一幕像三次互不相干的淡入。
- 首次进入展示区时,图片解码不能集中阻塞主线程。
- 动画不是作品信息的唯一载体;键盘和辅助技术用户仍应能在普通作品列表中访问每个链接。
换句话说,这一页的输入不是“经过了多少秒”,而是“读者在叙事中的位置”。页面滚动位置只是输入信号,真正被渲染的是一个受控的 0 ~ 1 进度。
scroll / resize | v目标进度 targetShowcaseProgress | vrequestAnimationFrame 平滑追赶 | v渲染进度 renderedShowcaseProgress | vphaseAt() 分镜区间 -> 卡片 / 快门 / 结尾场景固定舞台:用滚动距离换取阅读时间
展示区使用“长容器 + sticky 舞台”的结构。长容器提供滚动距离,舞台始终固定在视口内,所有镜头都在这个舞台上完成。
<section class="cinematic-showcase" id="showcase"> <div class="showcase-pin" data-showcase> <div class="showcase-intro">...</div> <div class="showcase-panels">...</div> <div class="showcase-interlude">...</div> <div class="showcase-finale">...</div> </div></section>当前 CSS 使用的是桌面端 850vh、窄屏端 760vh,而不是早期更短的数值:
.cinematic-showcase { position: relative; height: 850vh; margin-top: 2rem;}
.showcase-pin { position: sticky; top: 0; height: 100svh; min-height: 37.2rem; overflow: hidden; isolation: isolate;}
@media (max-width: 600px) { .cinematic-showcase { height: 760vh; }}这里的 850vh 不是动画持续时间,更像是时间轴的可用长度。真正可用的滚动距离不是容器高度本身,而是容器高度减去视口高度:
const measureShowcase = () => { if (!showcaseSection) return; showcaseStart = window.scrollY + showcaseSection.getBoundingClientRect().top; showcaseDistance = Math.max( 1, showcaseSection.offsetHeight - window.innerHeight, );};
const getShowcaseProgress = () => clamp((window.scrollY - showcaseStart) / showcaseDistance);这样做有两个细节。
第一,showcaseStart 在测量时写成相对文档的绝对位置,而不是每一帧都重新读取 getBoundingClientRect()。滚动路径中只需计算一次减法和一次夹取,避免不必要的布局读取。
第二,舞台高度选用 100svh。手机浏览器地址栏展开和收起时,svh 比传统 vh 更稳定;同时在 resize 时重新测量,并立即同步渲染进度,避免视口变化后动画从旧位置缓慢追赶到新位置。
const resizeShowcase = () => { measureShowcase(); targetShowcaseProgress = getShowcaseProgress(); renderedShowcaseProgress = targetShowcaseProgress; lastShowcaseProgress = -1; applyShowcaseProgress(renderedShowcaseProgress);};移动端并没有简单地把故事压缩得更快。当前实现缩短了总行程,并隐藏卡片中的长说明文字;保留编号和标题,让每个镜头仍有可理解的停留。
不在 scroll 回调里直接渲染
最容易写出的版本,是在每次 scroll 事件中读取进度并修改所有元素的 style。它在鼠标滚轮上似乎可行,但触摸板惯性、主线程短暂停顿和高刷新率设备会让同一段动画显得忽快忽慢。
当前实现把“用户想去哪里”和“当前画面画到哪里”分开:
const SCROLL_SCRUB_DURATION = 180;const SHOWCASE_SETTLE_EPSILON = .00008;
let targetShowcaseProgress = 0;let renderedShowcaseProgress = 0;let showcaseFrame = 0;let lastShowcaseTimestamp = 0;
const requestShowcaseUpdate = () => { targetShowcaseProgress = getShowcaseProgress(); if (showcaseFrame) return; lastShowcaseTimestamp = 0; showcaseFrame = window.requestAnimationFrame(renderShowcase);};滚动事件只更新目标值,并且以被动监听注册:
window.addEventListener("scroll", requestShowcaseUpdate, { passive: true });window.addEventListener("resize", resizeShowcase, { passive: true });实际渲染发生在 requestAnimationFrame 中。这里没有使用固定比例的 progress += 0.1,而是使用基于时间差的指数平滑:
const renderShowcase = (timestamp: number) => { const elapsed = lastShowcaseTimestamp ? Math.min(64, Math.max(0, timestamp - lastShowcaseTimestamp)) : 16.67;
lastShowcaseTimestamp = timestamp;
const blend = 1 - Math.exp(-elapsed / SCROLL_SCRUB_DURATION); renderedShowcaseProgress += (targetShowcaseProgress - renderedShowcaseProgress) * blend;
if ( Math.abs(targetShowcaseProgress - renderedShowcaseProgress) < SHOWCASE_SETTLE_EPSILON ) { renderedShowcaseProgress = targetShowcaseProgress; }
applyShowcaseProgress(renderedShowcaseProgress);
if ( Math.abs(targetShowcaseProgress - renderedShowcaseProgress) >= SHOWCASE_SETTLE_EPSILON ) { showcaseFrame = window.requestAnimationFrame(renderShowcase); return; }
showcaseFrame = 0; lastShowcaseTimestamp = 0;};blend = 1 - exp(-dt / duration) 的好处是刷新率无关:在 60Hz 和 120Hz 屏幕上,画面趋近目标的速度一致。elapsed 被限制到 64ms,标签页从后台恢复时也不会因一个异常大的时间差直接跳到终点。进度足够接近后停止申请下一帧,减少静止状态下的无效工作。
渲染函数内部还有一层更细的保护:当新旧进度相差小于 0.00001 时,不再重写样式。这一层在慢速滚动或停住后很有价值,因为页面的图层比普通卡片页多得多。
phaseAt:把连续进度切成可维护的分镜
所有镜头都从同一个 progress 出发,但每个镜头只关心属于自己的时间窗口。为此实现了一个“局部阶段函数”:
const clamp = (value: number) => Math.min(1, Math.max(0, value));
const ease = (value: number) => { const t = clamp(value); return t * t * (3 - 2 * t);};
const phaseAt = (progress: number, start: number, end: number) => ease((progress - start) / Math.max(.001, end - start));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。
卡片为什么要同时算透明度和几何退场
五张作品卡片由数据数组生成,动画代码只使用索引。入场和退场使用同一组局部进度:
const inProgress = phase(.04 + index * .055, .16 + index * .055);const dissolve = phase(.60, .72);const direction = index % 2 === 0 ? 1 : -1;
const startingY = (1 - inProgress) * direction * 150;const fanX = (index - 2) * dissolve * -3.25;const fanY = (index - 2) * dissolve * 1.35;
panel.style.opacity = String(inProgress * (1 - dissolve));panel.style.transform = "translate3d(" + fanX + "vw, " + (startingY + fanY) + "%, 0) " + "scale(" + (1 - dissolve * .12) + ") " + "rotate(" + ((index - 2) * dissolve * 1.35) + "deg)";入场时,奇偶卡片从相反方向进入;退场时,它们微微散开、缩小并旋转。更重要的是,透明度使用 inProgress * (1 - dissolve),所以退场结束时一定回到 0。
这个约束是有原因的。早期版本曾保留少量半透明残影,卡片边框会在纯黑转场和窗景上变成一层浅灰横带。对于这种需要明确切镜的页面,残影不是氛围,而是上一幕没有离开干净的证据。
卡片文字单独安排了入场和退场,避免与图片一起仓促消失:
const copyIn = phase(.16 + index * .05, .25 + index * .05);const copyOut = phase(.58, .70);
copy.style.opacity = String(copyIn * (1 - copyOut));copy.style.transform = "translate3d(0, " + ((1 - copyIn) * 18 - dissolve * 26) + "px, 0)";这让“图像抵达”和“文字可阅读”成为两个独立的节拍。设计上真正需要保证的不是卡片出现得多炫,而是最后一张卡片完全出现后,读者仍有机会理解它。
快门转场不是淡出,而是一段可交接的场景
卡片退场后,展示区进入黑场。这里的核心不是给画面叠一层黑色,而是明确每一层的职责:
| 图层 | 作用 |
|---|---|
| 黑色背景 | 切断前一段丰富的卡片画面,给下一幕提供干净底色 |
| 上下两条横幅 | 从左右移动,完成快门的合拢和再次展开 |
| 前景角色 | 在黑场中连接卡片阶段与结尾场景 |
| 两个大字 | 只在快门闭合后的短暂读秒出现 |
| 结尾场景 | 先在黑场后方完成准备,再在遮挡结束后被看见 |
上下横幅不是只进不出。它们先从画面外移动到中间,在角色和文字退出时回到两侧:
const stripsIn = phase(.72, .84);const stripsOut = phase(.91, .97);const stripPosition = stripsIn * (1 - stripsOut);
shutterStripLeft.style.transform = "translate3d(" + ((1 - stripPosition) * -108) + "%, 0, 0)";shutterStripRight.style.transform = "translate3d(" + ((1 - stripPosition) * 108) + "%, 0, 0)";结尾场景则从 0.88 就开始在快门后方进入:整体场景先渐显,视频和中景在 0.90 - 0.97 进入,前景角色到 0.94 - 1.00 才落位。这个重叠不是浪费,而是为了让读者先看到“场景已在”,再感受到前、中、后景的空间关系。
视频只在进度超过一个很小的阈值后尝试播放,并忽略被浏览器自动播放策略拒绝的结果:
const v = phase(.90, .97);finaleVideo.style.opacity = String(v);setVisibility(finaleVideo, v > 0);
if (v > .02 && finaleVideo.paused) { finaleVideo.play().catch(() => undefined);}visibility 会在图层完全不可见时关闭其可见性;视频仍然带有 muted、loop、playsinline 和 preload=“auto”。即使视频不能自动播放,静态中景和前景仍会完整呈现,结尾不会变成空白。
资源预热:不要把所有图片同时 decode
展示区一次会用到卡片、快门和结尾场景的多张图片。如果在首次进入页面时对所有图片立即调用 decode(),图片解码可能和首段滚动竞争主线程,反而让第一幕出现卡顿。
当前策略按两个图片为一组预热,每完成一组就让出一个动画帧:
const warmShowcaseMedia = async () => { for (const batch of showcaseImageBatches) { await Promise.all( batch.map(async (image) => { await waitForImage(image); await image.decode?.().catch(() => undefined); }), );
await new Promise((resolve) => { window.requestAnimationFrame(() => resolve()); }); }};waitForImage() 同时监听 load 和 error,单张资源失败不会把整条预热链卡住。这个策略不追求“最早把所有内容解码完”,而是控制一次被调度到主线程的工作量,让首页和第一段滚动仍保持响应。
动画层不承担链接职责
展示区不承担项目链接职责:卡片列表和快门转场容器标记为 aria-hidden,结尾场景中的图片使用空的 alt。真正可点击、带标题和描述的项目内容在下面的普通作品网格中:
<div class="showcase-panels" aria-hidden="true">...</div>
<section class="works-section" id="works"> <article class="work-card"> <a class="work-card-link" href="...">...</a> </article></section>这避免了同一批作品被屏幕阅读器重复朗读,也让动效失效、被跳过或尚未加载时,内容仍有一条稳定的阅读路径。首屏的“向下看作品”入口指向 #showcase,保证想体验叙事的读者进入动画开头;而普通网格则负责承载可访问的项目导航。
这里还有一个应继续完善的点:当前的 prefers-reduced-motion 已经关闭了常规内容的 reveal 过渡,但还没有为整段分镜提供专门的静态展示模式。由于作品链接不依赖动画层,内容不会丢失;下一步更理想的做法是让该媒体查询直接展示首帧或静态作品列表,并跳过滚动平滑与视频播放。
验证不是“看起来能动”
每次调整分镜后,我会从三个方向检查:
- 节奏:慢慢滚动时,第五张卡片和结尾文案是否都有可阅读的停留;快速滚动后画面是否平稳追上目标,而不是闪到终点。
- 图层:卡片退场后是否确实为 0;黑场是否干净;快门再次打开时,结尾场景是否已经准备好而没有白屏或裸露的中间状态。
- 环境:在展示区内改变窗口尺寸或旋转手机,进度是否保持;禁用或拒绝视频自动播放时,结尾是否仍可理解;键盘访问时,作品链接是否只在普通列表中出现一次。
内容改动完成后,仓库内使用以下命令检查:
pnpm checkpnpm type-checkpnpm build结语
这页最终留下的不是一套“滚动动画技巧”,而是一种更适合维护的拆分方式:
- CSS 管舞台、裁切和图层;
- 滚动事件只更新目标;
- rAF 负责在帧间平滑渲染;
- 分镜函数把连续进度切成可读的区间;
- 资源预热和可访问内容各自有独立边界。
当动画需要继续加镜头、换素材或调整节奏时,最重要的不是继续往页面里塞效果,而是先问清楚:这段变化处在哪个区间、它和前后镜头如何交接、内容是否仍有不依赖动画的路径。把这些问题写进结构之后,滚动才会真正成为叙事,而不是一串碰巧同时发生的视觉效果。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!






















































