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()
滚动叙事的尽头是克制 —— 用户感觉不到你调了三天参数,只觉得「这网站滚起来真舒服」,那你就成功了。