有一种滚动效果是这样的:页面在滚,背景却像一段剪好的片子,一张画面缓缓被下一张接走,中间看不到切换点。
它不是视差——视差让内容分层移动;这里内容正常滚,动的只有背景。也不是滚动劫持——滚轮没有被接管,只是被读了一遍。
结构:一条轨道,一个舞台
┌─ 轨道 height = 层数 × screensPerLayer × 100svh ─┐
│ │
│ ┌─ 舞台 position: sticky; top: 0 ──────────┐ │
│ │ 层 0 img │ │
│ │ 层 1 img │ │
│ │ 层 2 img │ │
│ │ scrim / 文案 │ │
│ └───────────────────────────────────────────┘ │
│ ↑ 滚过去,舞台不动,只换层 │
└──────────────────────────────────────────────────┘轨道高度决定这个效果能滚多久:三层各占一屏,总高 300svh,实际滚动距离是两屏。
进度怎么分给三层
const u = progress * (count - 1) // 3 层 → u ∈ [0, 2]
const edge = Math.min(0.45, hold / 2)
for (let i = 0; i < count; i += 1) {
const opacity = i === 0 ? 1 : smoothstep(i - 1 + edge, i - edge, u)
// 层 i 在 u = i 时刚好满不透明
}hold 是"到位之后稳一下"的比例:0 表示全程都在过渡,0.24 表示每层满不透明地停留一小段。smoothstep 让交接的两端都慢、中间快,所以两层的透明度是互补的——A 降多少,B 就升多少。
第一版是三角波,画面越滚越黑
最直觉的写法是给每层一个以自己为中心的三角波:离自己越近越不透明。三层各算各的,看起来完全合理。
截图直接打脸:滚动过程中整个画面在变暗,交接点最黑。
原因是 DOM 的叠放顺序。三个 <img> 是同一位置的兄弟节点,后画的压住先画的。当 A 的 opacity 是 0.3、B 是 0.7 时,屏幕上的合成不是 0.7B + 0.3A,而是:
0.7·B + 0.3·(0.3·A + 0.7·背景) = 0.7B + 0.09A + 0.21·背景两层都变暗了,还有 21% 的背景透出来。所以正确模型只有一种:上层淡入,下层始终满不透明。
A 不透明 → B 从 0 升到 1 盖住 A → A 已经不需要参与混合这样合成结果才等于 a·B + (1−a)·A,亮度守恒。这条现在写在组件注释里,防止以后被"优化"回三角波。
斜切只在过渡区间存在
split 模式给每层加一道对角裁切:
if (opacity > 0.002 && opacity < 0.998) {
const percent = ((1 - opacity) * split).toFixed(2)
el.style.clipPath = i % 2 === 0
? `polygon(0 ${percent}%, 100% 0, 100% 100%, 0 100%)`
: `polygon(0 0, 100% ${percent}%, 100% 100%, 0 100%)`
} else {
el.style.clipPath = 'none'
}奇偶层反方向切,两张图在过渡中各缺一角,合起来才是"拼接"而不是"叠化"。
clip-path 会触发重绘,所以它只在 0 < opacity < 1 时写,静止帧一律 none。重绘成本被限制在交接的那一小段,其余时间只有合成。
配合 parallax(相邻层上下错位 56px)和轻微的 scale,画面看起来是在重组,不是整体淡入淡出。
三个会出错的地方
- 轨道高度用 JS 设 → 首帧不知道多高,先短后长推一下版面(CLS)。现在是 CSS
calc(var(--sb-layers) * var(--sb-screens) * 100svh)。 - 首帧可见性写内联 style → 每帧都会被覆盖,父组件重渲染时还会把进度冲掉。现在由 CSS
:first-child保证第一层初始可见。 - 滚轮事件直接映射进度 → 滚轮是离散的,画面会一顿一顿。
smoothing做指数衰减,松手后还在往目标值收敛——衔接感有一半来自这里。
选图标准是「低频」,不是好看
第一版第三张用的是霓虹天桥:色彩很足,但画面里全是高频细节。两张高细节照片五五开叠加时,观感像信号串扰——不是不好看,是看不清。
换图之后的标准变成两条:
- 色相跨度够大:暖橙 → 青绿 → 蓝天,交接时能看出是"两个世界";
- 每张都有大片低频区域:天空、水面、墙面,给另一张留出可读的位置。
另外在交接中点额外压暗 scrimDip(默认 0.18),既让高细节叠加不刺眼,也保证压在图上的白字始终有对比度。
降级与位置
prefers-reduced-motion 下停在第一帧,滚动距离和版面高度都不变。不是把速度调成 0.01,也不是把整段删掉——版面跳动比动效本身更让人不适。
这个组件原本只放在 /craft 页尾。首页首屏已经有 DitherVeil,再挂一个常驻动效就违反了效果预算的第一条:一屏最多一个。
2026-11-05 从 /craft 撤下了:它要占三屏,放在一个「逛一圈看看有什么」的展品页里,读完设计系统还要再滚三段背景,节奏是断的。组件和素材都还在 src/components/effects/ 与 src/assets/ 里,要重新挂上去只是加一段 JSX。