Key answer: a 3D scroll website maps the scroll position to a real-time Three.js scene, so scrolling drives a camera, objects and materials instead of only moving the page. The reliable architecture is one pipeline — a tall scroll container produces a normalized progress value from 0 to 1, that value feeds an eased timeline, and the WebGL render loop reads it each frame. Keep the canvas as a presentation layer over crawlable HTML, cap devicePixelRatio, render on demand, and respect reduced motion. This guide builds that pipeline step by step with real code, covers manual scroll mapping versus GSAP ScrollTrigger, and is honest about when a 3D scroll website is the wrong choice.
Table of contents
- What a 3D scroll website is
- Why the architecture matters
- The scroll to progress to timeline model
- Create the renderer
- Map scroll to a 0 to 1 progress value
- Drive the scene from progress
- Pinning and scrubbing with GSAP ScrollTrigger
- Sync DOM content with the scene
- Performance on mobile GPUs
- SEO architecture
- Accessibility
- When to use and when not to
- Manual vs GSAP vs Lenis
- How AETumi approaches it
- Technical proof on GitHub
- FAQ
- Related resources
- Conclusion
What a 3D scroll website is
A 3D scroll website is a page where scroll position is the input to a WebGL animation. Instead of the browser simply translating the document, you read how far the user has scrolled, convert it to a value between 0 and 1, and use that value to interpolate a Three.js scene — the camera flies through a product, a model explodes into parts, 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: the canvas is a presentation layer, not the page. Your headings, copy, prices and links stay real HTML. That separation is exactly what lets a scroll-driven site remain fast, indexable and accessible while still feeling cinematic, and it is the first principle behind every production build on the AETumi 3D scroll hub.
Why the architecture matters
Most scroll scenes that fail do so for one reason: the animation logic is tangled directly into scroll event handlers, so every new beat means more fragile math and the whole thing breaks on resize or on mobile. The fix is architectural, not cosmetic. When you reduce the entire experience to a single progress value, you get something you can log, scrub and reason about — every animation becomes a pure function of one number.
That discipline is what separates a demo that looks good for five seconds from a page that ships. It decouples the choreography from the exact page height, so responsive layouts do not shatter the timing, and it makes the scene testable because you can jump to any moment by setting progress directly. For teams building 3D on top of Three.js or React Three Fiber, this is the difference between a scroll website that is an asset and one that is a maintenance liability.
The scroll to progress to timeline model
Every robust 3D scroll website reduces to the same flow: a scroll container produces a normalized progress value, that value feeds an eased timeline, and the render loop consumes the timeline to update the camera, objects and materials. One number bridges the DOM and the WebGL scene.
Keeping progress as a single normalized value is the whole trick. It is what makes the system predictable and what lets you add a second or third act later without touching the plumbing — you extend the timeline, not the wiring. The rest of this guide implements that pipeline in order, then layers on the production concerns most tutorials skip.
Create the renderer
Start with a minimal, 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 DPR 3 you render nine times the pixels of DPR 1. Capping to 2 — or 1.5 on low-end phones — cuts fill-rate dramatically with almost no visible quality loss on a moving scene. This one line protects the whole experience before you draw anything.
Map scroll to a 0 to 1 progress value
This mapping is the core of a 3D scroll website. Read the scroll, normalize it against the maximum scrollable distance, then smooth it so the camera 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 camera — lower is heavier and more cinematic, higher is snappier. 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 while building and during QA. A production note: keep the buy action and specifications in HTML, never baked into the canvas, so they stay crawlable and copyable.
Pinning and scrubbing with GSAP ScrollTrigger
Manual mapping is enough for a single moving scene. For multi-section 3D 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 the same easing you wrote by hand, and pin: true is what makes a section feel like a chapter rather than a passing element. This is the pattern behind most premium scrollytelling product pages.
Sync DOM content with the scene
A 3D scroll website 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 Three.js scene reads progress; the HTML reads visibility. Two simple, independent systems are far more maintainable than one giant scroll handler trying to do both, and they compose cleanly with reusable 3D components.
Performance on mobile GPUs
Scroll scenes are GPU-bound. Three levers matter most: cap DPR, 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 needsRender = true, visible = true
addEventListener('scroll', () => { needsRender = true }, { passive: 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) needsRender = true
if (!needsRender) return
needsRender = false
renderer.render(scene, camera)
}
Test on a mid-range Android phone, not a laptop — mobile GPUs are where scroll jank appears first. When a frame stalls, the two usual culprits are uncapped DPR and rendering every frame when nothing changed, both fixed above.
SEO architecture
A Three.js scroll website does not hurt SEO if the semantic layer stays in HTML. Search engines render and index the DOM; they do not read pixels in a canvas. Keep the crawlable structure real and let WebGL enhance it.
<section class="stage">
<canvas id="scene" aria-hidden="true"></canvas>
<div class="content">
<h1>AESPORT performance running shoe</h1>
<p>Carbon plate. 184g. $189.</p>
<a href="/product/aesport">View product</a>
</div>
</section>
The canvas is aria-hidden and decorative; the heading, spec and link are indexable text. That is the difference between a 3D page that ranks and a beautiful one that search engines cannot read — a principle covered in depth across our Three.js SEO guidance.
Accessibility
Respect prefers-reduced-motion. Users who opt out of motion should get the final composed state without the animation, and the page must remain fully usable with the keyboard.
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches
if (reduce) { progress = 1; renderer.render(scene, camera) } // skip the animated loop
An accessible 3D scroll website never traps content behind an animation. If motion is off, or WebGL fails, the HTML content is still there and readable — a non-negotiable for any commercial project, especially work delivered for agencies.
When to use and when not to
Reach for a 3D scroll website when one hero product deserves a cinematic reveal — a launch, a luxury item, hardware — and the 3D genuinely explains the product through an exploded view, materials or scale. It is the wrong tool when the page is content- or list-heavy, such as documentation or a large catalog, where 3D becomes decoration that adds load without meaning. It is also wrong when your audience is largely low-end mobile on slow networks and you cannot keep a fast HTML fallback within budget.
Manual vs GSAP vs Lenis
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. Lenis paired with ScrollTrigger gives a buttery smooth-scroll feel, but smooth-scroll can hurt accessibility and native scrolling if overused. Match the tool to the number of scenes and the smoothness you actually need, not to what looks impressive in a tutorial.
How AETumi approaches it
AETumi is an AI-native 3D web platform for production Three.js and WebGL websites, React and Next.js components, and MCP workflows for AI coding assistants such as Claude Code. For scroll-driven sites the AETumi standard is opinionated: progress-driven scenes, a crawlable HTML layer, capped DPR, on-demand rendering, and a reduced-motion path. Every pattern in this guide ships in real AETumi templates, and the same architecture 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 reference implementation is public: the AETumiApp/threejs-scroll-animation repository documents pin, scrub and reveal scrollytelling patterns and includes a runnable example scene. Use the repository as the code proof and this article as the architecture and decision context. The repository README covers the same progress-timeline model and its performance notes, so the two together give both the working code and the reasoning behind it.
FAQ
What is a 3D scroll website? A page where scroll position drives a real-time Three.js or WebGL scene. Scroll is normalized to a 0 to 1 progress value that a timeline and the render loop read each frame, so scrolling animates the camera, objects and materials over crawlable HTML.
Do I need GSAP to build one? No — you can map scrollY to progress and drive Three.js manually. GSAP ScrollTrigger is recommended for pinning sections, scrubbing timelines and handling resize and refresh reliably in production.
Does a Three.js scroll website hurt SEO? Not if the semantic content stays in HTML and the canvas is only presentation. Engines index the DOM, not the canvas, so keep headings, copy and links crawlable.
How do I keep it fast on mobile? Cap devicePixelRatio, render on demand, pause when the canvas is offscreen or the tab is hidden, and respect prefers-reduced-motion. Heavy scroll scenes are the most common cause of jank on mobile GPUs.
Can Claude Code generate a Three.js scroll section? Yes. With the AETumi MCP and prompt patterns, an AI coding assistant can scaffold a progress-driven scroll scene on owned source.
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
A 3D scroll website is not a trick — it is one clean pipeline: scroll to progress to timeline to render loop, wrapped in a crawlable, accessible, performance-capped shell. Get that architecture right 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 site becomes a durable asset rather than a fragile demo.
More from the AETumi library
Real, production-ready assets — preview the motion, grab the source.

