Three.js From Zero · Article s0-03

Scroll-Driven Portfolio Scene

Scroll-Driven Portfolio Scene is Article s0-03 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From ZeroS0-03 · Quick Wins

Season 0 · Article 03 · Quick Wins

The portfolio template every Three.js beginner asks for: a long page where the camera flies through 3D sections as the user scrolls. One canvas, three sections, a smooth lerp. ~150 lines.

Section 1 — Cube

Scroll down. The camera will track to the torus. ↓

Section 2 — Torus

Each section anchors a different 3D shape. ↓

Section 3 — Octahedron

Back to the top: the camera lerps continuously based on scroll fraction.

The mental model

The scene is fixed — three shapes anchored at fixed Y positions, evenly spaced. The scroll fraction (how far down the user has scrolled, 0 to 1) drives the camera Y position. Linear formula:

camera.position.y = -scrollFraction * (sections - 1) * sectionHeight;

That's it. The scroll-driven portfolio "trick" is one multiplication. Everything else is layout: making the canvas position: absolute behind a scrollable HTML overlay so users scroll a real page (no jank, no scroll hijacking) while the camera follows their progress.

Step 1 — The layout

Two stacked layers in the same container:

<div class="demo-host">
  <canvas>...</canvas>          <!-- z-index 1, pointer-events: none -->
  <div class="demo-scroll">     <!-- z-index 2, overflow-y: scroll -->
    <section>...</section>
    <section>...</section>
    <section>...</section>
  </div>
</div>

Critical CSS: the canvas has pointer-events: none so scroll gestures pass through to the HTML layer below. Without this, the canvas eats touches and scroll dies.

Pitfall. Tall sections (e.g., height: 100vh) are the convention, but the scroll math doesn't care — what matters is reading scrollTop / (scrollHeight - clientHeight) to get a normalized 0–1 fraction. That's the formula that works whether sections are short, tall, or different heights.

Step 2 — The shapes

const shapes = [
  new THREE.Mesh(new THREE.BoxGeometry(1.4,1.4,1.4), mat('#ec4899')),
  new THREE.Mesh(new THREE.TorusGeometry(0.9,0.32,16,32), mat('#3b82f6')),
  new THREE.Mesh(new THREE.OctahedronGeometry(1.1, 0), mat('#22c55e')),
];
shapes.forEach((s, i) => {
  s.position.y = -i * 4;          // one shape per 4 units of vertical space
  s.position.x = i % 2 === 0 ? 2 : -2;   // alternate sides
  scene.add(s);
});

Alternating shapes left/right gives the camera something to track sideways too — pure vertical scrolling feels flat.

Step 3 — Listen to scroll

const scroller = document.getElementById('demo-scroll');
let scrollFraction = 0;
scroller.addEventListener('scroll', () => {
  scrollFraction = scroller.scrollTop / (scroller.scrollHeight - scroller.clientHeight);
}, { passive: true });

The passive: true hint tells the browser this handler will never call preventDefault — it lets the scroll proceed without waiting for the JS, which keeps it buttery.

Step 4 — Lerp the camera

Don't snap the camera to the scroll value directly — that's twitchy on touch devices. Lerp toward it each frame:

const target = new THREE.Vector3();

renderer.setAnimationLoop(() => {
  target.set(0, -scrollFraction * (shapes.length - 1) * 4, 5);
  camera.position.lerp(target, 0.06);     // 6% per frame — smooth but responsive
  camera.lookAt(0, target.y, 0);

  shapes.forEach((s, i) => {
    s.rotation.x = performance.now() * 0.0003 * (i + 1);
    s.rotation.y = performance.now() * 0.0005 * (i + 1);
  });

  renderer.render(scene, camera);
});

The 0.06 factor is the lerp speed. Smaller = more inertia (cinematic). Larger = snappier. 0.06 is the sweet spot for a portfolio site that feels alive but not laggy.

Step 5 — The parallax touch

Add cursor parallax for the polish that makes people share the URL:

const cursor = { x: 0, y: 0 };
window.addEventListener('mousemove', (e) => {
  cursor.x = (e.clientX / window.innerWidth - 0.5) * 2;
  cursor.y = (e.clientY / window.innerHeight - 0.5) * 2;
});

// inside the loop, after camera.lookAt:
camera.position.x += (cursor.x * 0.6 - camera.position.x + 0) * 0.04;

That nudges X by a fraction of the cursor offset every frame — the camera drifts toward where you're looking. Subtle, but it's the difference between "scrolling site" and "alive scene".

Common first-time pitfalls

"Scroll doesn't work." The canvas is sitting on top of the scrollable div without pointer-events: none. Touch/wheel events get eaten before they reach the scroller.
"Camera jumps to bottom on first scroll." You forgot to lerp — you set camera.position.y = -scrollFraction * ... directly. Always lerp.
"Shapes are invisible when at sections 2 and 3." Camera far plane is too close. With four units between sections × three sections, you need far ≥ 30 to be safe.

Exercises

  1. Cross-fade colors. Each section's HTML has a background gradient. Lerp the canvas scene.background color based on which section is dominant — same lerp pattern, but on color.
  2. Add scroll-driven shape rotation. Instead of constant time rotation, drive each shape's rotation off scrollFraction. Watch shapes wake up as you scroll past them.
  3. Use IntersectionObserver. Replace the scroll-position math with IntersectionObserver on each section element. Snap the camera to whichever section is most visible. Smoother feel on mobile.