Key answer: scroll progress mapping converts raw scroll position into a single normalized value between 0 and 1 that every part of a 3D scene reads. Compute it as scrollY divided by the maximum scrollable distance, ease it toward its target for smoothness, then split it into per-section sub-ranges with a remap-and-clamp helper so each act of the animation gets its own local 0 to 1. This one value is the contract between the DOM and the WebGL scene — get the mapping right and the camera, objects and materials all become pure functions of scroll. This guide builds the mapping, the easing, the per-section remap, and the small utilities that make a multi-beat 3D scroll animation predictable.
Table of contents
- What progress mapping is
- The base scroll-to-0-1 formula
- Easing the value
- Remap and clamp: the core helpers
- Per-section progress ranges
- Easing curves inside a range
- Element-relative progress
- Debugging with a progress readout
- Performance
- Accessibility
- How AETumi maps progress
- Technical proof on GitHub
- FAQ
- Related resources
- Conclusion
What progress mapping is
Progress mapping is the quiet foundation of every 3D scroll website. Before a camera moves or a model rotates, the scroll position has to become a number the scene can trust — normalized, bounded and stable. That number, a value from 0 at the top to 1 at the bottom, is what the whole animation reads. Everything cinematic is downstream of getting this one mapping right.
The reason it matters, and the reason it anchors the 3D scroll hub, is decoupling. When the scene depends only on a normalized value rather than on pixel offsets or page height, responsive layouts stop breaking your timing and you can jump to any moment by setting the value directly. Progress mapping is what turns a fragile scroll demo into a system you can reason about.
The base scroll-to-0-1 formula
The base mapping divides the current scroll by the total scrollable distance. Guard the divide so an unscrollable page reports 0 rather than a division error.
function scrollProgress() {
const max = document.documentElement.scrollHeight - innerHeight
return max > 0 ? scrollY / max : 0
}
This yields 0 at the very top and 1 when the page is scrolled to the bottom, independent of how tall the page happens to be. Because it is relative to the maximum, a short page and a tall page both produce the same clean 0 to 1 range, which is exactly why the scene should read this value and never raw pixels.
Easing the value
Raw progress tracks the scrollbar exactly, which feels mechanical. Ease it toward its target each frame so the scene carries momentum and settles smoothly after the scroll stops.
let target = 0, progress = 0
addEventListener('scroll', () => target = scrollProgress(), { passive: true })
function frame() {
requestAnimationFrame(frame)
progress += (target - progress) * 0.08 // exponential smoothing
updateScene(progress)
renderer.render(scene, camera)
}
frame()
The 0.08 factor sets how heavily the scene follows the scroll — the single knob that tunes the feel of the entire page. Keep the eased progress as the value the scene reads, and treat target as the instantaneous input it chases.
Remap and clamp: the core helpers
The two utilities that make multi-beat animation manageable are remap and clamp. Remap rescales a value from one range into another; clamp bounds it. Together they let you carve the global progress into local sub-ranges.
const clamp = (v, a, b) => Math.min(Math.max(v, a), b)
const remap = (v, inA, inB, outA = 0, outB = 1) =>
outA + (clamp(v, inA, inB) - inA) / (inB - inA) * (outB - outA)
With these, remap(progress, 0.2, 0.5) returns a fresh 0 to 1 value that is 0 until progress reaches 0.2 and 1 once it passes 0.5. This is the single most useful pattern in scroll animation — it turns one global number into as many independent local timelines as you need, and it underpins every Three.js scroll scene worth shipping.
Per-section progress ranges
A real page has several acts, each occupying a slice of the scroll. Assign each act a range and remap the global progress into it, so every act animates through its own full 0 to 1 while the page uses one input.
function updateScene(p) {
const intro = remap(p, 0.00, 0.30) // logo assembles
const reveal = remap(p, 0.30, 0.65) // product rotates in
const detail = remap(p, 0.65, 1.00) // camera pushes to a feature
logo.scale.setScalar(intro)
model.rotation.y = reveal * Math.PI * 2
camera.position.z = 6 - detail * 3
}
Each act is clamped to its slice, so the intro holds its finished state while the reveal plays, and nothing bleeds across boundaries. Adding a fourth act means adding one more remap line, not rewiring the plumbing — the extensibility that makes this architecture durable across interactive websites.
Easing curves inside a range
Linear remapped ranges are fine for mechanical moves but flat for expressive ones. Apply an easing function to a local range to add acceleration and settle.
const easeInOut = t => t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t+2, 2)/2
const reveal = easeInOut(remap(p, 0.30, 0.65))
Easing the local value, not the global progress, means each act can have its own character — a snappy intro, a slow luxurious reveal — without affecting the others. Keep the global progress linear and let per-range curves shape the feel, so the mapping stays predictable while the motion stays expressive.
Element-relative progress
Section ranges by fraction work until the layout changes and your hand-picked fractions drift. For robustness, compute progress relative to a specific element's position in the viewport, so a beat is tied to its own section rather than to a guessed percentage.
function elementProgress(el) {
const r = el.getBoundingClientRect()
return clamp((innerHeight - r.top) / (innerHeight + r.height), 0, 1)
}
This returns 0 as the element enters from below and 1 as it exits the top, regardless of page height. Element-relative progress is the resilient choice when content length is dynamic, and it pairs cleanly with the fixed fractions above for hybrid layouts.
Debugging with a progress readout
Because the entire animation is one number, the fastest way to debug it is to see that number. Drop a live readout on screen while building, then remove it before shipping.
const hud = document.querySelector('#hud')
function updateScene(p) { hud.textContent = p.toFixed(3); /* ...drive scene... */ }
Watching progress against what the scene does makes every timing bug obvious — a beat that fires too early is a range boundary to nudge, not a mystery. This is the payoff of reducing the system to one value: it is trivially observable, which is why AETumi keeps a progress HUD in every scroll 3D component during development.
Performance
Progress mapping itself is a couple of arithmetic operations per frame, effectively free. The cost is the render it drives, so apply the usual discipline: cap devicePixelRatio, render on demand when the eased progress actually changes, and pause when the canvas is offscreen. Because the scene reads one value, on-demand rendering is trivial — repaint only when progress moved beyond a small epsilon, and rest otherwise.
Accessibility
Under reduced motion, resolve progress to its final state so the scene shows its composed result without the scroll-driven journey.
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
updateScene(1); renderer.render(scene, camera)
}
Setting progress to 1 and rendering once gives the reduced-motion visitor the finished scene, while the HTML content stays fully available — the baseline for commercial work delivered for agencies.
How AETumi maps progress
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. Every AETumi scroll scene is built on this mapping layer: a guarded scroll-to-0-1 base, exponential easing, remap-and-clamp sub-ranges, and per-range curves. Because the mapping is a small, well-defined utility set, it can be scaffolded through an MCP workflow and reused unchanged across projects, which is what keeps the choreography consistent from site to site.
Technical proof on GitHub
The AETumiApp/threejs-scroll-animation repository ships the progress mapping and remap helpers described here inside a runnable scene, so you can read the utilities in context. Use the repository for the working code and this article for the reasoning behind ranges, easing and element-relative progress.
FAQ
How do I convert scroll position to a 0 to 1 value? Divide scrollY by the maximum scrollable distance, which is scrollHeight minus innerHeight, and guard against dividing by zero. This gives 0 at the top and 1 at the bottom regardless of page height.
How do I give each section its own progress? Use a remap-and-clamp helper to rescale the global progress into each section's sub-range. remap(progress, 0.3, 0.65) returns a fresh 0 to 1 that plays only across that slice of the page.
Should I ease the progress value? Yes for feel. Ease the value toward its target each frame with exponential smoothing so the scene settles after scrolling stops. Keep the eased value for the scene and the raw value as the target it chases.
What is element-relative progress? Progress computed from an element's position in the viewport rather than a fixed page fraction, so a beat stays tied to its section even when content length changes. It is the resilient choice for dynamic layouts.
Does progress mapping affect performance? The math is free; the render it drives is not. Cap devicePixelRatio, render on demand when progress changes beyond a small threshold, and pause offscreen.
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
Progress mapping is the contract that makes 3D scroll animation sane: scroll to a guarded 0 to 1 value, ease it, and carve it into per-section ranges with remap and clamp. Every camera move, rotation and material fade becomes a pure function of that one number, observable with a HUD and resolvable to a final state for reduced motion. Build the mapping layer first and the cinematic part becomes arithmetic you can trust.
More from the AETumi library
Real, production-ready assets — preview the motion, grab the source.

