Scroll-Driven Camera Animation

12 hours ago 3

Camera

Planted: May 2025

Status: seed

Hits: 474

Intended Audience: Creative coders, Front-end developers

Tech: three.js, GSAP

This note details how to make a camera move around a three.js scene as the user scrolls.

Two layers

The page is made of two layers:

  • Background: A three.js scene, position: fixed so it remains within the viewport as the user scrolls.
  • Foreground: A column of sections, each containing text.

Blueprint

Intersection observer

When a section intersects the center of the viewport, the scene's camera changes to a different position. This is controlled using a data-camera-state attribute on each section, with values: floor, cube, cone, or sphere. An intersection observer watches the sections and triggers a move camera function based on the attribute.

...

<main>

<section data-camera-state="floor">

...

function initIntersectionObserver(camera) {

const copySections = document.querySelectorAll(

`.${config.classNames.copySection}`

);

if (copySections.length === 0) {

throw new Error(`Elements not found`);

}

const options = { threshold: "0.5" };

function onIntersect(entries) {

if (config.gui.enableOrbitControls) {

return;

}

entries.forEach((entry) => {

if (entry.isIntersecting) {

const state = entry.target.getAttribute(config.attributes.cameraState);

if (state) {

moveCameraImpl({ camera, state });

}

}

});

}

const observer = new IntersectionObserver(onIntersect, options);

copySections.forEach((section) => {

observer.observe(section);

});

}

To avoid rapid camera movement, each section needs a minimum height of at least half the viewport height — I'm using 80vh.

Camera positions

Each camera state has:

  • position: the camera's coordinates (x, y, z)
  • lookAt: the coordinates the camera is looking at

const config = {

camera: {

positions: {

floor: {

position: [-1, 17, 0],

lookAt: [-1, 0, 0]

},

cube: {

position: [11, 3, 9],

lookAt: [-2, 0, 3]

},

...

To make it easy to capture desired camera positions, I've added a GUI option enableOrbitControls. When enabled, the page enters a dev mode:

  • The Intersection Observer is disabled
  • Orbit controls are enabled
  • A listener logs the camera position and target to the console whenever the camera moves

This lets us freely explore the scene. When we find a position we like, we can copy and paste the logged values into the config object.

function initLoggingCameraPosition({ controls, camera }) {

function logCameraPosition() {

if (!config.gui.enableOrbitControls) {

return;

}

const { position } = camera;

const { target } = controls;

const positionImpl = [position.x, position.y, position.z].map(Math.round);

const targetImpl = [target.x, target.y, target.z].map(Math.round);

console.log("Camera Position:", positionImpl);

console.log("Camera Target:", targetImpl);

const data = {

position: positionImpl,

target: targetImpl

};

const json = JSON.stringify(data);

navigator.clipboard.writeText(json);

}

controls.addEventListener("change", logCameraPosition);

}

Moving the camera

GSAP is used to animate the camera movement. For position, we apply the config's position coordinates to camera.position. For rotation, instead of changing camera.rotation (Euler angles), we use camera.quaternion. While Euler angles (x, y, z) are intuitive, they can cause issues like gimbal lock and result in choppy transitions. Quaternions are a more robust way to represent 3D rotation and avoids those issues. Note: camera.rotation and camera.quaternion are linked - updating one automatically updates the other.

function getLookAtQuaternion({ position, lookAt }) {

const tempCam = new THREE.PerspectiveCamera();

tempCam.position.copy(new THREE.Vector3(...position));

tempCam.lookAt(new THREE.Vector3(...lookAt));

return tempCam.quaternion.clone();

}

function moveCamera({ camera, position, lookAt }) {

const targetQuat = getLookAtQuaternion({ position, lookAt });

gsap.to(camera.position, {

x: position[0],

y: position[1],

z: position[2],

...config.camera.animation

});

gsap.to(camera.quaternion, {

x: targetQuat.x,

y: targetQuat.y,

z: targetQuat.z,

w: targetQuat.w,

...config.camera.animation,

onUpdate: () => camera.updateMatrixWorld()

});

}

Feedback

Have any feedback about this note or just want to comment on the state of the economy?

Where to next?

Arrow pointing down

YOU ARE HERE

A Johnny Cab pilot

Read Entire Article