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.
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.
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
pointer-events: none. Touch/wheel events get eaten before they reach the scroller.camera.position.y = -scrollFraction * ... directly. Always lerp.far plane is too close. With four units between sections × three sections, you need far ≥ 30 to be safe.Exercises
- Cross-fade colors. Each section's HTML has a background gradient. Lerp the canvas
scene.backgroundcolor based on which section is dominant — same lerp pattern, but on color. - 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. - Use IntersectionObserver. Replace the scroll-position math with
IntersectionObserveron each section element. Snap the camera to whichever section is most visible. Smoother feel on mobile.
UP NEXT
S0-04 — 3D Text Playground → TextGeometry, bevels, animated extrusions. Your first 3D "made by you" badge.