Two solid approaches — pick ONE source of truth for scroll so things don't fight.
- Approach A — Lenis + GSAP ScrollTrigger: normal DOM page scrolls; scroll drives 3D. Best for marketing/story pages mixing HTML and 3D.
- Approach B — drei ScrollControls: scroll lives inside the Canvas over a virtual track. Best for pure 3D experiences.
Approach A — Lenis + GSAP ScrollTrigger
Install:
npm i gsap @studio-freight/lenis
Smooth scroll + sync to ScrollTrigger (run once at app root):
import Lenis from '@studio-freight/lenis'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useEffect } from 'react'
gsap.registerPlugin(ScrollTrigger)
export function useSmoothScroll() {
useEffect(() => {
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true })
lenis.on('scroll', ScrollTrigger.update)
const raf = (time: number) => lenis.raf(time * 1000)
gsap.ticker.add(raf)
gsap.ticker.lagSmoothing(0)
return () => { gsap.ticker.remove(raf); lenis.destroy() }
}, [])
}
Drive the 3D camera from scroll. Share scroll progress via a store, then read it in useFrame:
// store.ts
import { create } from 'zustand'
export const useScrollStore = create<{ progress: number }>(() => ({ progress: 0 }))
// in a component (outside Canvas) — map a section to progress 0..1
useEffect(() => {
const st = ScrollTrigger.create({
trigger: '#scene-section',
start: 'top top',
end: 'bottom bottom',
scrub: 1,
onUpdate: (self) => useScrollStore.setState({ progress: self.progress }),
})
return () => st.kill()
}, [])
// inside Canvas — move camera along a path
import { CatmullRomCurve3, Vector3 } from 'three'
const path = new CatmullRomCurve3([
new Vector3(0, 1.5, 6), new Vector3(3, 2, 2), new Vector3(0, 4, -4),
])
useFrame(() => {
const p = useScrollStore.getState().progress
const pos = path.getPointAt(p)
camera.position.lerp(pos, 0.1)
camera.lookAt(0, 0, 0)
invalidate() // if frameloop="demand"
})
Approach B — drei ScrollControls
import { ScrollControls, Scroll, useScroll } from '@react-three/drei'
<ScrollControls pages={3} damping={0.2}>
<Experience /> {/* reads useScroll().offset in useFrame */}
<Scroll html> {/* HTML that scrolls with the track */}
<section>…</section>
</Scroll>
</ScrollControls>
// inside Experience:
const scroll = useScroll()
useFrame(() => {
const o = scroll.offset // 0..1
group.current.rotation.y = o * Math.PI * 2
})
Always include
- Reduced motion: if
matchMedia('(prefers-reduced-motion: reduce)')matches, disable smooth scroll and jump-cut instead of scrubbing. - Cleanup: kill ScrollTriggers / destroy Lenis on unmount.
- Mobile: test touch scroll; if Lenis feels off on mobile, consider
smoothTouch:false. - Damping: lerp camera/values for smoothness; tune
scrubandlerp.
Verify
- Scroll through the page and confirm the camera/objects move smoothly in sync, no jitter, correct cleanup on route change.
- For complex choreography, hand off to the motion-choreographer agent.