

Scroll-driven camera animation
Planted:
Status: seed
Hits: 5560
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.
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.
/index.html
...<main><section data-camera-state="floor">...
/index.js
function initIntersectionObserver(camera) {const copySections = document.querySelectorAll(`.${config.classNames.copySection}`);if (copySections.length === 0) {throw new Error(`Elements not found`);}// I would use rootMargin instead of threshold but there seems to be a bug with codepen// rootMargin: "-50% 0%"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
/index.js
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.
/index.js
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.
/index.js
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?
