Build a Scroll-Driven 3D Animation with Three.js
Video: Build a Scroll-Driven 3D Animation with Three.js by CelesteAI
Scroll-driven 3D is the trick behind a lot of award-winning landing pages: you scroll, and a 3D object responds — rotating, zooming, assembling, dissolving. It looks expensive. It isn’t. The whole effect comes down to one idea: make the scene a pure function of a single number between 0 and 1.
That number is your scroll progress. 0 is the top of the page, 1 is the bottom. Once the entire scene — camera, rotation, color, even a particle explosion — is computed from that one value, two good things happen. In the browser, the scrollbar drives it for free. And because nothing depends on a clock, you can render the exact same animation to a video, frame for frame.
This post builds the whole thing in about a hundred lines of Three.js.
The idea: one number drives everything
The mistake most people make is animating against time — requestAnimationFrame ticking a clock forward. That’s fine for ambient motion, but it fights scrolling. Instead, write one function:
function renderAt(progress) {
// progress is 0 → 1. Everything below is derived from it.
}
No Date.now(), no elapsed time. Give it 0.5 and you always get the exact middle of the animation. That property — same input, same frame — is what makes it both scrubbable and recordable.
Getting Three.js into your page properly
Three.js ships as an ES module, so you can’t just drop in a <script src> tag and expect a global THREE. There are two clean ways to set it up.
No build step — an import map. Perfect for a single page. Point the bare three specifier at a CDN build, then import it from a module script:
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
// …your scene code here
</script>
A real project — npm and a bundler. For anything bigger, install it and let Vite resolve the imports:
npm install three
npm create vite@latest
import * as THREE from "three";
One gotcha either way: ES modules don’t load over file://. Serve the folder over HTTP — npx serve or python3 -m http.server — and open localhost, not the file itself. The HTML shell is just a canvas and a module script:
<canvas id="bg"></canvas>
<script type="module" src="./main.js"></script>
Setup: scene, camera, renderer
Standard Three.js boilerplate. A scene, a perspective camera, and a renderer pointed at a full-screen canvas.
import * as THREE from "three";
const canvas = document.getElementById("bg");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
scene.add(new THREE.AmbientLight(0x8899ff, 0.5));
const key = new THREE.DirectionalLight(0xffffff, 2.2);
key.position.set(3, 4, 5);
scene.add(key);
The hero shape
A low-poly icosahedron with flatShading reads as faceted and modern. Lay a wireframe over the top so the structure stays visible as it spins.
const geo = new THREE.IcosahedronGeometry(1.4, 2);
const solid = new THREE.Mesh(
geo,
new THREE.MeshStandardMaterial({
color: 0x14b8a6, roughness: 0.35, metalness: 0.6, flatShading: true,
})
);
const shell = new THREE.LineSegments(
new THREE.WireframeGeometry(geo),
new THREE.LineBasicMaterial({ color: 0x99f6e4, transparent: true })
);
scene.add(solid, shell);
renderAt(progress): the pure function
This is the heart of the effect. Every line reads progress and nothing else. A small smooth easing curve keeps the motion from feeling robotic.
const lerp = (a, b, t) => a + (b - a) * t;
const smooth = (t) => t * t * (3 - 2 * t);
function renderAt(progress) {
const p = Math.min(1, Math.max(0, progress));
// Camera dollies in and lifts as you scroll.
camera.position.set(0, lerp(0.2, 1.1, smooth(p)), lerp(7.5, 3.4, smooth(p)));
camera.lookAt(0, 0, 0);
// Rotation is driven by progress — NOT by a clock.
const spin = p * Math.PI * 2.2;
solid.rotation.set(spin * 0.6, spin, 0);
shell.rotation.copy(solid.rotation);
// Background hue drifts from deep navy to violet.
renderer.setClearColor(new THREE.Color().setHSL(lerp(0.62, 0.78, p), 0.55, 0.04), 1);
renderer.render(scene, camera);
}
Hook it to the scrollbar
Now wire the page’s scroll position into progress. Divide how far you’ve scrolled by the maximum scrollable distance to get 0 → 1, then ease toward it so the motion stays smooth even when the wheel jumps.
let target = 0, current = 0;
function scrollProgress() {
const max = document.body.scrollHeight - window.innerHeight;
return max > 0 ? window.scrollY / max : 0;
}
window.addEventListener("scroll", () => { target = scrollProgress(); });
function tick() {
current += (target - current) * 0.12; // ease toward the scroll target
renderAt(current);
requestAnimationFrame(tick);
}
tick();
Make the page tall enough to scroll — a few full-height sections — and the icosahedron now answers the scrollbar. A thin progress bar fixed to the top (width: progress * 100%) makes the connection obvious to first-time visitors.
The payoff: shatter it into particles
A rotating shape is nice. A shape that detonates past the halfway point is memorable. Build one particle per vertex, precompute an outward direction for each, and push them out as a second progress band ramps up.
const src = geo.attributes.position;
const COUNT = src.count;
const home = new Float32Array(COUNT * 3);
const dir = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
const x = src.getX(i), y = src.getY(i), z = src.getZ(i);
home[i * 3] = x; home[i * 3 + 1] = y; home[i * 3 + 2] = z;
const len = Math.hypot(x, y, z) || 1;
dir[i * 3] = x / len; dir[i * 3 + 1] = y / len; dir[i * 3 + 2] = z / len;
}
const pGeo = new THREE.BufferGeometry();
pGeo.setAttribute("position", new THREE.BufferAttribute(home.slice(), 3));
const particles = new THREE.Points(
pGeo,
new THREE.PointsMaterial({ color: 0x5eead4, size: 0.06, transparent: true, opacity: 0 })
);
scene.add(particles);
Then, inside renderAt, drive the burst from a remapped slice of progress — only the back half of the scroll. As the particles fly out, fade the solid surface away.
const clamp01 = (t) => Math.min(1, Math.max(0, t));
const span = (p, a, b) => clamp01((p - a) / (b - a));
// …inside renderAt(p):
const burst = span(p, 0.5, 1.0); // 0 until halfway, then 0 → 1
const reach = smooth(burst) * 3.2;
const pos = particles.geometry.attributes.position;
for (let i = 0; i < COUNT; i++) {
pos.setXYZ(
i,
home[i * 3] + dir[i * 3] * reach,
home[i * 3 + 1] + dir[i * 3 + 1] * reach,
home[i * 3 + 2] + dir[i * 3 + 2] * reach
);
}
pos.needsUpdate = true;
particles.material.opacity = burst;
solid.material.transparent = true;
solid.material.opacity = 1 - burst;
The first half of the scroll spins the shape toward you; the second half blows it apart. One variable, two acts.
Bonus: render it to video — frame-perfect
Here’s the part the pure-function design buys you for free. Because the scene depends only on progress, you don’t have to screen-record it and hope for a smooth take. You can step progress yourself and screenshot each frame:
// In a headless browser (Puppeteer), for a 10-second 25fps clip:
for (let i = 0; i < 250; i++) {
const progress = i / 249;
await page.evaluate((p) => window.renderAt(p), progress);
await page.screenshot({ path: `frame_${i}.png` });
}
// Then: ffmpeg -framerate 25 -i frame_%d.png -pix_fmt yuv420p demo.mp4
No dropped frames, no jitter, no dependence on how fast the GPU is. The same clip every time. That’s only possible because the animation never reads a clock.
Recap
- Make the scene a pure function of
progress(0 → 1). No clocks. Same input, same frame. - Scroll maps to progress with
scrollY / maxScroll; ease toward it for smooth motion. - Layer the motion in bands —
span(p, 0.5, 1.0)lets the second half do something the first half doesn’t. - Pure-function scenes render to video deterministically — step the number, screenshot, encode.
Want the deeper dive?
The full, runnable source — the scroll-driven page plus the headless capture script — is on GitHub. Clone it, open index.html, and scroll.