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()

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