Key answer: scroll-driven 3D animation with Three.js works by making the scene a pure function of one normalized progress value. Read the scroll position, divide it by the maximum scrollable distance to get a value from 0 to 1, ease it, and let the render loop drive the camera, objects and materials from that number each frame. For a single moving scene, map scroll manually; for pinned, multi-section storytelling, add GSAP ScrollTrigger to own the pin and scrub. Keep the canvas a decorative layer over crawlable HTML, cap devicePixelRatio, render on demand, and honor reduced motion. This guide builds the scroll-to-progress pipeline step by step, adds GSAP for pinning, and covers the performance, SEO and accessibility that make it production-ready.
Table of contents
- What scroll-driven 3D animation is
- The one-value architecture
- Set up the renderer
- Map scroll to a 0 to 1 value
- Drive the scene from progress
- Add GSAP ScrollTrigger for pinning
- Sync HTML captions
- Performance on mobile
- SEO and accessibility
- Manual mapping versus GSAP
- How AETumi builds scroll animation
- Technical proof on GitHub
- FAQ
- Related resources
- Conclusion
What scroll-driven 3D animation is
Scroll-driven 3D animation is the technique where scroll position is the input to a real-time Three.js scene. As the visitor scrolls, the browser reports how far down the page they are; you convert that to a value between 0 and 1 and use it to interpolate the scene — the camera flies, a model rotates, a material dissolves. The document still scrolls; the difference is that a render loop reads the scroll each frame and repaints a canvas.
The mental model that keeps this maintainable is that the canvas is a presentation layer, not the page. Your headings, copy and links stay real HTML. That separation is what lets scroll-driven 3D stay fast, indexable and accessible while feeling cinematic, and it is the first principle across the 3D scroll hub.
The one-value architecture
Every robust scroll-driven Three.js animation reduces to one flow: scroll produces a normalized progress value, that value feeds an eased timeline, and the render loop consumes it to update the scene. One number bridges the DOM and the WebGL scene.
Keeping progress as a single normalized value is the whole trick. It makes the system predictable — you can log it, scrub it, and jump to any moment by setting it directly — and it decouples the animation from exact page height, so responsive layouts do not shatter the timing. Every animation becomes a pure function of one input, the architecture that separates a scene that ships from a demo that breaks on resize, and the foundation of scroll work on Three.js.
Set up the renderer
Start with a capped renderer. The device-pixel-ratio cap is the single most important line for scroll performance — uncapped retina rendering is the usual reason a scroll scene stutters on real devices.
import * as THREE from 'three'
const canvas = document.querySelector('#scene')
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) // cap: never full retina
renderer.setSize(innerWidth, innerHeight)
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100)
camera.position.set(0, 0, 6)
At devicePixelRatio 3 you render nine times the pixels of ratio 1. Capping to 2 cuts fill-rate dramatically with almost no visible quality loss on a moving scene, protecting the whole experience before you draw anything.
Map scroll to a 0 to 1 value
This mapping is the core of scroll-driven animation. Read the scroll, normalize it against the maximum scrollable distance, and smooth it so the scene never snaps.
let target = 0, progress = 0
function readScroll() {
const max = document.documentElement.scrollHeight - innerHeight
target = max > 0 ? scrollY / max : 0
}
addEventListener('scroll', readScroll, { passive: true }); readScroll()
// in the render loop: exponential smoothing
progress += (target - progress) * 0.08
The progress value eases toward the real scroll position. The 0.08 factor is the weight of the scene — lower is heavier and more cinematic, higher is snappier. The passive listener keeps native scrolling smooth. Every animation below reads this one value and nothing else.
Drive the scene from progress
With progress in hand, the scene becomes a function of scroll. Rotate an object, dolly the camera, fade a material — all keyed to the same number.
const knot = new THREE.Mesh(
new THREE.TorusKnotGeometry(1, 0.32, 160, 24),
new THREE.MeshStandardMaterial({ color: 0x7c8bff, roughness: 0.35 })
)
scene.add(knot, new THREE.HemisphereLight(0xbfd0ff, 0x0a0a18, 1.2))
function frame() {
requestAnimationFrame(frame)
progress += (target - progress) * 0.08
knot.rotation.y = progress * Math.PI * 2 // full turn across the page
camera.position.z = 6 - progress * 3 // dolly in as you scroll
renderer.render(scene, camera)
}
frame()
Because the scene reads progress and nothing else, you can preview any point by setting progress directly — invaluable during build and QA. Keep any buy action or specifications in HTML, never baked into the canvas, so they stay crawlable and copyable, a discipline shared across interactive websites.
Add GSAP ScrollTrigger for pinning
Manual mapping is enough for a single moving scene. For multi-section storytelling — where a scene pins in place while you scroll through it — GSAP ScrollTrigger is the production-grade tool. It owns the pin, the scrub and the resize-and-refresh lifecycle so you do not reinvent them.
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
const state = { p: 0 }
gsap.to(state, {
p: 1, ease: 'none',
scrollTrigger: { trigger: '#stage', start: 'top top', end: '+=2000', scrub: 1, pin: true }
})
// the render loop reads state.p exactly like progress above
The scrub value adds easing, and pin: true makes a section feel like a chapter rather than a passing element. This is the pattern behind most premium scrollytelling, and it drops cleanly into a reusable 3D component.
Sync HTML captions
Scroll animation usually needs HTML captions to appear in step with 3D beats. Use IntersectionObserver for the DOM side rather than more scroll math — it is cheaper and does not fight the render loop.
const io = new IntersectionObserver((entries) => {
for (const e of entries) e.target.classList.toggle('-in', e.isIntersecting)
}, { threshold: 0.5 })
document.querySelectorAll('.caption').forEach(el => io.observe(el))
The scene reads progress; the HTML reads visibility. Two simple independent systems are far more maintainable than one giant scroll handler trying to do both.
Performance on mobile
Scroll scenes are GPU-bound. Three levers matter most: cap devicePixelRatio, render on demand, and stop entirely when the canvas is off-screen. On-demand rendering alone often halves battery use on a resting page.
let visible = true
new IntersectionObserver(([e]) => visible = e.isIntersecting).observe(canvas)
function frame() {
requestAnimationFrame(frame)
if (!visible || document.hidden) return // parked
progress += (target - progress) * 0.08
if (Math.abs(target - progress) < 1e-4) return // settled: skip render
renderer.render(scene, camera)
}
Test on a mid-range Android phone, not a laptop — mobile GPUs are where scroll jank appears first. Uncapped devicePixelRatio and rendering every frame when nothing changed are the two usual culprits, both fixed above.
SEO and accessibility
Scroll animation does not hurt SEO if the semantic layer stays in HTML, and it stays accessible if it honors reduced motion. Keep the heading, copy and links crawlable beside an aria-hidden canvas, and resolve to a final state when motion is reduced.
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
progress = 1; renderer.render(scene, camera) // final state, no animation
}
Search engines index the DOM, not the canvas, so real HTML content ranks while the WebGL enhances it. And because content never hides behind the animation, a reduced-motion or WebGL-less visitor still gets the full page — the baseline for commercial work delivered for agencies.
Manual mapping versus GSAP
Manual scrollY with a lerp is best for one moving scene with minimal dependencies, at the cost of owning pin, resize and refresh yourself. GSAP ScrollTrigger is best for multi-section pinning and scrubbing, at the cost of a library and learning its trigger lifecycle. Match the tool to the number of scenes and the smoothness you actually need — reach for manual mapping first, and add GSAP when you genuinely need pinning across several sections, not because it looks impressive in a tutorial.
How AETumi builds scroll animation
AETumi is an AI-native 3D web platform for production Three.js and WebGL sites, React and Next.js components, and MCP workflows for assistants such as Claude Code. The AETumi standard for scroll animation is opinionated: progress-driven scenes, a crawlable HTML layer, capped devicePixelRatio, on-demand rendering, and a reduced-motion path. Every pattern here ships in real templates, and the same scene can be scaffolded through an MCP workflow so an assistant edits owned source rather than generating a scene from nothing.
Technical proof on GitHub
The AETumiApp/threejs-scroll-animation repository documents the pin, scrub and reveal patterns and includes a runnable example scene built on the progress-timeline model described here. Use the repository as the code proof and this article as the architecture and decision context.
FAQ
How do I animate a Three.js scene on scroll? Map scroll position to a 0 to 1 value by dividing scrollY by the maximum scrollable distance, ease it, and drive the camera, objects and materials from that value in the render loop each frame. Never mutate the scene inside a scroll event handler.
Do I need GSAP for Three.js scroll animation? No — you can map scrollY to progress and drive Three.js manually for a single moving scene. GSAP ScrollTrigger is recommended when you need to pin sections and scrub multi-section timelines with reliable resize and refresh handling.
Does Three.js scroll animation hurt SEO? Not if the semantic content stays in HTML and the canvas is decorative. Search engines index the DOM, not the canvas, so keep headings, copy and links crawlable and mark the canvas aria-hidden.
How do I keep scroll animation fast on mobile? Cap devicePixelRatio, render on demand, pause when the canvas is offscreen or the tab hidden, and use passive scroll listeners. Heavy scroll scenes are the most common cause of jank on mobile GPUs.
How do I make scroll animation accessible? Honor prefers-reduced-motion by resolving the scene to its final state instead of animating, keep all content in HTML so nothing hides behind the animation, and never hijack native scroll so the keyboard keeps working.
Related resources
Continue with the 3D scroll hub, Three.js, React Three Fiber, reusable 3D components, interactive websites, the MCP workflow, and options for agencies.
Conclusion
Scroll-driven 3D animation with Three.js is one clean pipeline: scroll to a normalized progress value, ease it, and drive the scene from that single number, with GSAP ScrollTrigger added when you need pinning. Wrap it in a crawlable, accessible, performance-capped shell and the cinematic part becomes easy to extend and safe to ship. Build the pipeline once, keep the HTML honest, and a scroll-driven Three.js scene becomes a durable asset rather than a fragile demo.
More from the AETumi library
Real, production-ready assets — preview the motion, grab the source.

