2026.05.14
滚动叙事的手感工程:Lenis 与 ScrollTrigger 的正确打开方式
为什么有的网站滚起来像丝绸,有的像生锈的齿轮?拆解平滑滚动背后的工程细节:单一 rAF 时钟、数字型 scrub、固定舞台模式,以及那些没人告诉你的调参玄学。
判断一个交互型网站的完成度,我有个私人标准:闭上眼睛滚三下。手感对了,其他都不会太差。这篇文章总结我做滚动叙事页面时沉淀下来的工程套路。
为什么原生滚动「不够用」
原生滚动是阶梯式的:滚轮每格跳 100px 左右,动画跟着一顿一顿。Lenis 做的事情很简单 —— 拦截输入,把目标位置用缓动函数「追」出来:
import Lenis from 'lenis'
const lenis = new Lenis({
duration: 1.15,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
})
但装上 Lenis 只是开始。真正的坑在于:页面里现在有几个时钟?
一个页面只允许一个 rAF 循环
Lenis 要 raf,GSAP 有自己的 ticker,Three.js 还有渲染循环。三个 requestAnimationFrame 各跑各的,帧与帧之间互相踩踏,这就是「莫名卡一下」的元凶。解法是全部挂到 GSAP 的单一 ticker 上:
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => {
lenis.raf(time * 1000) // GSAP 用秒,Lenis 用毫秒
})
gsap.ticker.lagSmoothing(0)
从此滚动、补间、渲染共享一个心跳。
scrub 永远写数字
scrub: true 是 1:1 硬绑定,快速甩动滚轮时动画会「抽搐」。写一个数字,它就变成了追赶延迟(秒):
gsap.to(camera.position, {
z: 4,
ease: 'none',
scrollTrigger: {
trigger: '#journey',
start: 'top top',
end: 'bottom bottom',
scrub: 0.8, // 0.8 秒的“惯性”,抖动全部被抹平
},
})
0.6 ~ 1.0 之间的值手感最好。再大就开始「晕船」了。
固定舞台,而不是 pin
滚动驱动 3D 场景时,教程都爱用 pin: true。但 pin 会克隆 DOM、改写布局,跟 Lenis、跟别的动画库打架是常事。更稳的做法是舞台固定 + 幽灵滚动条:
.stage {
position: fixed;
inset: 0;
z-index: 0; /* 3D 永远铺在底下 */
}
.scroll-driver {
height: 500vh; /* 一个透明的高个子,只负责产生滚动距离 */
}
ScrollTrigger.create({
trigger: '.scroll-driver',
start: 'top top',
end: 'bottom bottom',
scrub: 0.8,
onUpdate: (self) => {
world.progress = self.progress // 写进共享状态,useFrame 里读
},
})
DOM 层滚 DOM 的,3D 层读进度做自己的事,两边解耦,谁也不干扰谁。
数值写进 ref,不写进 state
滚动进度每秒变化上百次。放进 React state,等于每秒上百次重渲染:
// ❌ 整棵组件树给你表演原地爆炸
const [progress, setProgress] = useState(0)
// ✅ 可变单例 + useFrame 直读,零重渲染
export const world = { progress: 0 }
useFrame(() => {
camera.position.z = THREE.MathUtils.damp(
camera.position.z,
6.5 - world.progress * 2.6,
2.2,
delta,
)
})
别忘了那 5% 的用户
平滑滚动是甜点,不是主食。prefers-reduced-motion 开启时,Lenis 干脆不要初始化:
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
if (!reduced) initLenis()
2026 补记:原生 Scroll-Driven Animations 到哪一步了
CSS 原生的滚动驱动动画(animation-timeline: scroll() / view())在 2026 年的主流浏览器覆盖率终于能打了。简单的进度条、视差、进场 reveal,现在可以零 JS 实现:
.reading-progress {
animation: grow linear both;
animation-timeline: scroll(root);
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
我的分工标准:纯视觉、单元素、不需要回调的滚动效果交给 CSS;需要驱动 3D 世界、需要 onUpdate 写共享状态、需要和 Lenis 时钟同步的,继续留在 ScrollTrigger。混用时注意一件事——CSS 时间轴读的是原生滚动位置,Lenis 的 smoothWheel 恰好也是真实滚动(它拦截输入而不是伪造 transform),所以两者天然兼容;但如果你用的是「transform 假滚动」类方案,CSS 时间轴会直接失灵。
平滑滚动之上:整页的动效编排
滚动手感只是「手感工程」的第一层。这个站在它之上还叠了三层,全部遵守同一个 reduced-motion 开关与单一 rAF 时钟原则:
- 路由过场:一道斜向光带扫过视口(GSAP 补间
xPercent),代替生硬的页面跳变 - 主题切换:从点击位置扩散的圆形幕布,盖满屏幕的那一帧换装——四套主题的实现拆解见 一套代码,四个平行世界
- 微交互:卡片 3D 倾斜追光、按钮磁吸、
Ctrl+K命令面板的弹簧进场
滚动叙事的尽头是克制 —— 用户感觉不到你调了三天参数,只觉得「这网站滚起来真舒服」,那你就成功了。