从 Neo-Brutalism 到全屏叙事风格的重构过程,以及用 motion 做文字动画的一点心得。
这个博客前后改过两次大版本。第一版是 Neo-Brutalism 风格——粗边框、高饱和色块,做出来之后看了两天就腻了。第二版是现在这个:极黑背景、全屏分区、大字重排版。
为什么这样排版
设计文档里写的定位是「沉浸式、电影感、极简主义」。具体落地的时候,主要做了三个决定。
背景是 #050505,没用纯黑。 纯黑 #000000 在有些屏幕上会出现「溢出感」,和非纯黑的元素边界很生硬。#050505 肉眼几乎看不出差别,但和白色文字的对比更柔和一点。
首页用全屏 snap scroll 分区。 每个 section 占满视口,snap-y snap-proximity 控制滚动吸附。这样每屏只承载一个主题——Hero、项目、文章、联系——不会让人一进来就面对一堵信息墙。代价是在移动端要额外处理高度,100dvh 比 100vh 在浏览器地址栏展开/收起时更稳定。
字号用 vw 单位,不写固定值。 首页大标题是 text-[15vw] md:text-[8vw],在不同屏幕宽度下自动缩放,不需要一堆断点覆盖。缺点是超宽屏会显得过大,目前还没处理这个边界情况。
motion 的感受
用的是 motion/react(v12),之前叫 Framer Motion。
最常用的模式是「进入视口一次后触发」。用 IntersectionObserver 包了个 useInViewOnce hook,element 进入视口后把 active 置为 true,然后把 active 传给各个动画组件控制播放。这样页面滚动到某个 section 时,动画只播一次,不会来回触发。
const workSection = useInViewOnce<HTMLElement>();
// ...
<section ref={workSection.ref}>
<ShimmerSweep active={workSection.active}>PROJECTS_</ShimmerSweep>
</section>
文字动画拆成了几种独立组件,按场景选用:
SoftBlurChars:逐字 blur + fade in,用于 Hero 大标题,时间感比较强TypewriterChars:逐字打字机效果,用于 Hero 副标题那句话LineByLineSlide:整行从左侧滑入,用于多行标题MicroScaleFade:轻微缩放 + 淡入,用于数字、按钮等小元素ShimmerSweep:整块横向滑入 + blur,用于 section 标题
每种动画都有对应的 easing 函数,SoftBlurChars 用 cubicBezier(0.22, 1, 0.36, 1)(快出慢入),KineticCenterWords 用 cubicBezier(0.2, 0.8, 0.2, 1)(稍硬一点)。easing 的差别肉眼很难说清,但混在一起就是会有「不对」的感觉,所以每类动画单独调。
所有组件都检查了 useReducedMotion(),如果用户系统开了「减少动效」就直接渲染静态内容。这是做动画时容易漏掉的无障碍细节。
有什么不满意的地方
移动端首页的 snap scroll 在低端机上有时候会卡顿,主要是 blur 动画的 GPU 压力。filter: blur() 会强制走合成层,同时多个元素在做这个效果的时候帧率会掉。目前还没有好的解法,只能减少同时触发的动画数量。
另一个问题是首页右侧的 Hero 图只在 md 以上显示,移动端那块空间是空的。本来想放一个纯 CSS 的渐变占位,但总觉得没有比有更干净,就先留空了。
整体来说 motion 的 API 设计很好,声明式写动画比手动写 requestAnimationFrame 省心很多。主要的坑是性能——特别是逐字符动画,每个字是一个独立的 DOM 节点,长文本下节点数很快就上百了。