Key answer: scroll-driven camera animation in Three.js works when the camera is a pure function of one normalized progress value. You define a fixed path — either two keyframe points you interpolate, or a CatmullRomCurve3 the camera samples — then map scroll from 0 to 1, ease it, and each frame set camera.position and a lookAt target from that value. Never mutate the camera inside a scroll event handler; drive everything from the render loop reading progress. Cap devicePixelRatio, ease the progress so the camera glides instead of snapping, and give reduced-motion users the final framing without the fly-through. This guide builds a scroll camera two ways, the two-point lerp and the curve, and covers FOV, lookAt and the mistakes that cause camera jitter.
Table of contents
- What scroll camera animation means
- The one-value rule for cameras
- Two-point camera interpolation
- Camera paths with CatmullRomCurve3
- Where to look: animating lookAt
- Easing FOV for a dolly-zoom feel
- Smoothing to kill camera jitter
- Multi-section camera choreography
- Performance and mobile
- Accessibility and reduced motion
- How AETumi builds scroll cameras
- Technical proof on GitHub
- FAQ
- Related resources
- Conclusion
What scroll camera animation means
Scroll-driven camera animation is the technique behind almost every cinematic 3D web page: as the visitor scrolls, the camera flies through a scene — pushing in on a product, arcing around a model, pulling back to reveal scale. The scroll is not moving the objects; it is moving the point of view. Done well it feels like a directed shot. Done badly it stutters, overshoots and makes people motion-sick.
The reliable version treats the camera exactly like any other scroll-driven element on the 3D scroll hub: its position, its target and its field of view are all outputs of a single progress value between 0 and 1. Get that relationship right and the camera becomes something you can scrub, preview and reason about frame by frame.
The one-value rule for cameras
The single most common camera bug is writing camera.position.z -= 1 inside a scroll listener. Scroll events fire irregularly, batch on some browsers, and never fire at all when you jump with the keyboard or reload mid-page. Accumulating state from those events guarantees drift.
Instead, compute an absolute progress value each frame and derive the camera from it. Absolute mapping means the camera is always correct for the current scroll position, no matter how the user got there — a refresh at 60% down the page lands on exactly the right shot. This is the same architecture used across Three.js scroll work: one number in, a full camera state out.
Two-point camera interpolation
The simplest useful camera moves in a straight line between a start and an end framing. Store both as vectors and lerp between them by progress.
import * as THREE from 'three'
const camStart = new THREE.Vector3(0, 1.5, 8)
const camEnd = new THREE.Vector3(2, 0.4, 2.6)
const lookStart = new THREE.Vector3(0, 0.5, 0)
const lookEnd = new THREE.Vector3(0.3, 0.2, 0)
const _look = new THREE.Vector3()
function updateCamera(progress) {
camera.position.lerpVectors(camStart, camEnd, progress)
_look.lerpVectors(lookStart, lookEnd, progress)
camera.lookAt(_look)
}
lerpVectors interpolates position, and a second lerp moves the target the camera looks at, so both the eye and its focus travel together. This alone is enough for a clean push-in on a single hero object, and it is the pattern to reach for before adding any curve.
Camera paths with CatmullRomCurve3
For an arc or a fly-through with more than two beats, sample a smooth curve instead. CatmullRomCurve3 passes through every control point you give it, so you author the shot as a handful of positions and let Three.js interpolate the path.
const path = new THREE.CatmullRomCurve3([
new THREE.Vector3(0, 1.5, 8),
new THREE.Vector3(3, 1.0, 4),
new THREE.Vector3(1, 0.3, 2),
new THREE.Vector3(-1, 0.6, 3)
])
function updateCamera(progress) {
path.getPoint(progress, camera.position) // writes into camera.position
camera.lookAt(0, 0.4, 0)
}
getPoint accepts your 0 to 1 progress directly and writes the sampled position into the camera, so the entire fly-through is still driven by one value. Increase the control points to add beats without changing a line of the scroll plumbing — the path holds the choreography, progress holds the timing.
Where to look: animating lookAt
A moving camera with a fixed target feels robotic. The shot comes alive when the focus shifts — the camera pushes toward a product while its gaze drifts to the detail you want noticed. Interpolate the lookAt target the same way you interpolate position, and consider easing it on a slightly different curve so the focus leads or trails the move.
The one caution: calling lookAt every frame overrides any manual rotation, so pick one owner of camera orientation. If you also want subtle idle sway, apply it as a small offset to the lookAt target rather than rotating the camera directly, keeping a single source of truth for where the camera aims — the discipline that keeps reusable 3D components composable.
Easing FOV for a dolly-zoom feel
Animating field of view alongside position produces the famous dolly-zoom, or vertigo, effect — the background appears to rush while the subject stays fixed. Even a few degrees of FOV change adds cinematic tension to a scroll reveal.
const fovStart = 55, fovEnd = 34
function updateCamera(progress) {
camera.fov = fovStart + (fovEnd - fovStart) * progress
camera.updateProjectionMatrix() // required after changing fov
}
The critical line is updateProjectionMatrix: change FOV without it and nothing happens on screen. Keep FOV moves subtle — large swings distort geometry and trigger motion discomfort faster than any position move.
Smoothing to kill camera jitter
Raw scroll values are noisy, and a camera bound directly to them looks nervous. Ease the progress toward its target every frame so the camera carries weight.
let target = 0, progress = 0
addEventListener('scroll', () => {
const max = document.documentElement.scrollHeight - innerHeight
target = max > 0 ? scrollY / max : 0
}, { passive: true })
function frame() {
requestAnimationFrame(frame)
progress += (target - progress) * 0.07 // lower = heavier camera
updateCamera(progress)
renderer.render(scene, camera)
}
frame()
The 0.07 factor is the mass of the camera. A heavier value glides and feels expensive; a snappier value tracks the scrollbar tightly. Tune this one number to set the entire personality of the shot.
Multi-section camera choreography
Longer pages want distinct shots per section rather than one continuous sweep. Map each section to a sub-range of progress and remap it locally to 0 to 1, so each act has its own framing while the global scroll stays the single input.
const remap = (p, a, b) => THREE.MathUtils.clamp((p - a) / (b - a), 0, 1)
function updateCamera(p) {
if (p < 0.5) path.getPoint(remap(p, 0, 0.5), camera.position) // act one
else path2.getPoint(remap(p, 0.5, 1), camera.position) // act two
}
Clamping each sub-range means a section holds its final framing until the next one begins, so shots never bleed together. This is how a scroll page reads as a sequence of deliberate camera moves instead of one long drift, and it composes naturally with pinned interactive websites.
Performance and mobile
A scroll camera is cheap to compute but the render it triggers is not. Cap devicePixelRatio to 2, render on demand when progress actually changes, and pause the loop when the canvas leaves the viewport. Camera math running every frame is fine; repainting a full-resolution retina frame when nothing moved is what drains a phone battery. Test the fly-through on a mid-range Android device, where any overshoot or frame stall shows up long before it does on a laptop.
Accessibility and reduced motion
Camera fly-throughs are exactly the kind of motion that triggers vestibular discomfort, so honoring the reduced-motion setting is not optional here. When it is set, skip the animated path and place the camera at its final, most legible framing.
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
updateCamera(1); renderer.render(scene, camera) // final shot, no fly-through
}
The visitor still sees the fully composed scene and every HTML heading, price and link stays readable — the accessibility floor for any commercial build delivered for agencies.
How AETumi builds scroll cameras
AETumi is an AI-native 3D web platform for production Three.js and WebGL sites, React and Next.js components, and MCP workflows for assistants such as Claude Code. The house rule for scroll cameras is the one in this guide: a single eased progress value drives position, lookAt and FOV; per-section ranges are clamped; reduced motion resolves to a final shot. Because the choreography lives in data — points and ranges — the same camera rig can be scaffolded through an MCP workflow and reused across projects instead of rewritten each time.
Technical proof on GitHub
The AETumiApp/threejs-scroll-animation repository includes a runnable scroll scene with the progress-to-camera pipeline described here, so you can read the working code alongside this architecture. Pair the repository for the implementation with this article for the reasoning about paths, lookAt and easing.
FAQ
How do I move a camera on scroll in Three.js? Map scroll position to a 0 to 1 value, ease it, and each frame set camera.position and lookAt from that value — using a two-point lerp for simple pushes or a CatmullRomCurve3 for a fly-through. Never change the camera inside a scroll event handler.
Why does my scroll camera jitter? Almost always because it reads raw scroll events and accumulates state. Compute an absolute progress value each frame and smooth it toward the target with exponential easing so the camera glides.
How do I animate the camera's field of view on scroll? Interpolate camera.fov between two values by progress and call camera.updateProjectionMatrix() afterward — without that call the FOV change does not render. Keep the swing small to avoid distortion.
Can I have different camera shots per section? Yes. Map each section to a sub-range of the global progress, clamp and remap it to 0 to 1 locally, and drive a per-section path or keyframes from it so shots stay distinct.
Does a scroll camera hurt performance? The math is cheap; the render is not. Cap devicePixelRatio, render on demand, and pause offscreen. That keeps a fly-through smooth even on mid-range mobile GPUs.
Related resources
Continue with the 3D scroll hub, Three.js, React Three Fiber, reusable 3D components, interactive websites, the MCP workflow, and options for agencies.
Conclusion
A scroll-driven camera is not a special case — it is the one-value pipeline pointed at the point of view. Author the shot as two vectors or a curve, ease the progress, drive position, lookAt and FOV from it, and give reduced-motion users the final framing. Keep that discipline and your camera reads as a directed shot that ships, not a demo that lurches on the first real phone.
More from the AETumi library
Real, production-ready assets — preview the motion, grab the source.

