1315 字
7 分钟
页面浏览--访问次数--
从一张明信片到五幕横滑:mmz 首页复刻的取舍

缘起#

一直很喜欢参考站那套「一张明信片缓缓收缩,然后横向滑过五幕,最后放大落进正文」的叙事。它没有导航、没有卡片瀑布流,整个首屏就是一段可以被滚轮驱动的短动画。

这次把它完整搬进 Shirone 的 mmz 皮肤:新页面变体 src/themes/mmz/pages/Home.astro 负责舞台,src/themes/mmz/home-dialogue.ts 负责控制器,其余交给 CSS 变量。下面是几个关键取舍。

首页首帧:明信片铺满视口
首帧是一张铺满视口的明信片,右下角站着会说话的卡通角色

一、双层明信片:收缩的是「内层」,不是整张图#

图上看着像整张明信片在缩小,实际是两层:外层固定尺寸的舞台 + 内层可变换的明信片。滚动进度只写一个变量:

.mmz-postcard {
transform: translate3d(var(--mmz-card-x), var(--mmz-card-y), 0)
scale(var(--mmz-card-scale)) rotate(var(--mmz-card-rotate));
}

控制器的职责只有一件事——根据滚动进度算出 --mmz-card-scale 等几个值,其余动画全部由 CSS 消费。这样做的好处是:

  • 变换只在合成层做,不触发重排;
  • 想调手感只改插值区间,不用碰 DOM 结构;
  • 首帧落位由 SSR 直接写死,不会出现「先铺满再突然缩小」的闪跳。

二、五幕横滑:把时间轴横过来#

五幕就是五个 100vw 宽的段落,容器 display: flex,滚动进度换算成 translateX。每一幕内部再做各自的入场:图片位移、文字淡入、遮罩展开,进度都用同一个 0 → 1 映射。

这里踩过的坑是高度。横滑容器不能用 height: 100vh,否则移动端浏览器地址栏伸缩时会把下面内容顶飞。统一用 100lvh 并用 --mmz-stage-height 兜底,Swup 切页回来也不会跳。

三、终幕:放大 + 文案居中 + 内容区上滑#

最后一幕不再横移,而是把当前画面整体放大、压暗,让居中文案浮出来。滚过阈值后:

  1. 放大层 scale 到设定值,边缘用径向渐变收口;
  2. 终幕文案逐行 translateY 上浮 + 淡入;
  3. 内容区从屏幕下方 translateY(100%) 滑上来,接管后续滚动。
终幕与内容区交接
终幕之后内容区上滑接管滚动,近期文章与我的动态并排出现

之所以让内容区「滑上来」而不是「淡入」,是因为淡入会让人感觉首屏还没结束;上滑则明确告诉访客:叙事结束了,现在可以正常读文章。

四、内容区两列:等高、无底色#

内容区只有四个模块:快速带你前往、公告、近期文章、我的动态。左右两列做成严格对称:

  • 两列 flex: 1,面板内部再用 display: flex; flex-direction: column;
  • 列表区 flex: 1 撑满剩余高度,内部 overflow-y: auto,列表再长也不会把对面那一列拉歪;
  • 面板去掉了底色,只留一圈细描边,和整站的 surface 令牌对齐。

早期版本里两列各用各的卡片底色,结果一列是白的、一列是偏灰的,摆在拼图纹理上像两块补丁。改成「无底色 + 细描边」后,纹理能透上来,观感统一了。

五、页脚与左上角的个人信息卡#

页脚保留了完整的备案信息:ICP 备案号、公安备案徽标与备案号、版权行、RSS / Atom / Sitemap、Powered by 与版本号。排版用 flex-wrap 让它在窄屏自然换行,徽标固定 0.875rem,不再挤压文字。

左上角是鼠标悬浮才展开的个人信息卡,它属于持久外壳,不在 #swup-container 里。所以它监听的是 Swup 的 page:view,而不是页面自己的生命周期——否则从首页切到文章页后,卡片会留在上一个页面的状态。

页脚备案信息
备案号、徽标与版权行在窄屏下自然换行,不再互相挤压

六、相册:扇形卡堆叠与点击展开#

首屏下方接了一组扇形卡堆叠,默认展示第一个相册。交互分两段:

  • 堆叠态:卡片按索引做位移、旋转、缩放,形成扇形;hover 时整组轻微上浮;
  • 展开态:点击任意一张,其余卡片淡出,被点的那张 flipFromRect 飞到屏幕中央变成大焦点图,再点一次或按 Esc 收回。

用的是 src/utils/motion.ts 里的 flipFromRect 和 revealIn,两条都是纯 CSS transition 驱动,并且遵守 prefersReducedMotion()——关掉动效时直接显示终态,不做飞行。

七、控制器:一个 rAF 管住整场#

所有滚动相关的写入都收在一个 rAF 里:

  • scroll 事件只置脏标记,真正的计算放到下一帧;
  • 每帧只算一次进度,值没变就跳过写入;
  • 图片 decode() 完成后再校准一次占位高度,避免图片晚到导致舞台高度算错;
  • destroy 时统一移除监听、取消 rAF、断开 ResizeObserver。

这套写法在 Swup 下尤其重要:页面被替换时如果还有 rAF 在跑,会对着已经卸载的 DOM 写样式。控制器导出 destroy,由页面在卸载钩子里调用。

收尾#

复刻一个首页,难的从来不是某个动画怎么写,而是怎么让五段互不相干的动画读起来像一段。答案是把它们压缩成同一个滚动进度,再让 CSS 各自消费;控制器只负责算数,不负责表演。

老规矩,npx.cmd astro check 0 errors、Playwright 实拍核对、pnpm.cmd build 通过之后才上线。

从一张明信片到五幕横滑:mmz 首页复刻的取舍
https://blog.fqzlr.top/posts/tech/mmz-home-postcard-replica/
作者
fqzlr
发布于
2026-11-01
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取