Back to Blog

Build a Scroll-Driven 3D Animation with Three.js

Celest KimCelest Kim

Video: Build a Scroll-Driven 3D Animation with Three.js by CelesteAI

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

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 bandsspan(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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.