AETumi — AI-native 3D web platform for Three.js, WebGL and interactive websites
News & Guides

Three.js Scroll Animation: How to Build Scroll-Driven 3D

September 7, 2026 · AETumi

A Three.js scroll animation works by mapping scroll progress — a 0-to-1 value for how far the page or a pinned section has advanced — onto scene state such as camera position, model rotation, or material values, and driving that from scroll rather than from a time-based clock. The reliable pattern is: read scroll progress, write it to a target variable, and let your own requestAnimationFrame loop ease the scene toward that target. Keep the scroll listener and the render loop decoupled, cap the pixel ratio, pause rendering when the canvas is offscreen, and always ship a reduced-motion fallback. That decoupling is what separates a smooth, cinematic scroll experience from a janky one, and it is the pattern behind every well-built scroll template.

Table of contents

What scroll-driven 3D actually is

Scroll is the one interaction every visitor already performs, which makes it the most natural control surface on the web. A Three.js scroll animation ties that gesture to a WebGL scene: as the visitor moves down the page, a model rotates, a product explodes into its parts, or a camera flies through a set. Each scroll becomes a deliberate step through a story, so attention follows the motion.

From idea to productionAETumi technical diagram — From idea to productionTrack scroll progressMap to animationPin or scrubThrottle offscreenReduced-motion fallback
From idea to production
Auralis
Auralis — live preview from the AETumi library

The distinction that matters is scroll-driven versus scroll-jacked. Driven means the animation follows the scrollbar and the visitor stays in control — scroll up and the scene rewinds, stop and it holds still. Jacked means the page hijacks the wheel to force a fixed sequence at its own pace; that is the version that feels broken. Everything below aims at the first: a scene whose state is a pure function of how far the user has scrolled.

What you'll learn

  • How to compute a normalized 0-to-1 scroll progress value for a page or a pinned section
  • How to map that progress onto Three.js scene state (camera, rotation, materials) cleanly
  • When to reach for pin-and-scrub versus a one-shot reveal
  • How to wire GSAP ScrollTrigger without coupling it to your render loop
  • Why you must decouple the scroll event from renderer.render() and how to lerp for smoothness
  • The React Three Fiber equivalent using useScroll and useFrame
  • The performance, SEO, and accessibility budget that keeps a scroll scene shippable

Why scroll-driven 3D matters

A conventional landing page asks the visitor to read; a scroll-driven 3D page invites them to operate something. When a product rotates to reveal exactly the feature the adjacent paragraph describes, comprehension and dwell time rise together, because the motion is doing explanatory work rather than decorating the page. For product launches, agency portfolios, and premium brand sites, this is the difference between a page that is scanned and one that is experienced.

AETumi live WebGL scene — real-time Three.js rendered in your browser, not a video.

It matters technically, too. Scroll-driven animation is deterministic — the frame at 40% scroll is always the same frame — which makes it easier to test, reason about, and make accessible than free-running autoplay. The cost is discipline: a scroll scene is a real-time render budget you pay on every device, a budget the templates in this guide treat as a first-class constraint rather than an afterthought.

One connected systemAETumi technical diagram — One connected systemSCROLL3DScrollprogressPin / scrubGSAPThree.jsRevealFallback
One connected system

Architecture: input, state, render

Every robust Three.js scroll animation follows the same three-part system model, and most bugs come from collapsing two of the parts into one: an input layer that measures scroll and produces a target progress value; a small state object that holds current and target progress; and a render layer — a single requestAnimationFrame loop — that eases current toward target and writes the result onto the scene.

The critical rule is that the input layer never renders. Scroll events fire in unpredictable bursts unaligned to display frames, so triggering rendering directly gives you redundant draws, dropped frames, and torn motion. Route everything through a shared state variable and render only on the animation-frame clock, and you get one render per frame regardless of how many scroll events arrive, plus a natural place to add easing. The rest of the implementation falls out of this model.

Scroll progress to animation mapping

The core idea is a single normalized value. For the whole page, progress is how far the scroll position has advanced through the total scrollable height:

function scrollProgress() {
  const max = document.documentElement.scrollHeight - window.innerHeight;
  return max > 0 ? window.scrollY / max : 0; // 0 → 1
}

This returns 0 at the very top and 1 at the very bottom. For a pinned section you compute progress relative to that section's own scroll range rather than the whole page, which is what lets a scene play out over "two screens' worth" of scrolling while staying fixed on screen. Either way you get a 0-to-1 number, then map it onto scene state:

// progress: 0 → 1
mesh.rotation.y = progress * Math.PI * 2;   // one full turn across the section
camera.position.z = 6 - progress * 3;       // dolly in as you scroll

Keep the mapping explicit and readable — one line per property, no hidden math. Expected behavior: at progress 0 the mesh faces forward with the camera at z=6; at progress 1 it has turned a full 360° and dollied to z=3. Trade-off: linear mapping is predictable but can feel mechanical; wrapping the value in an easing curve (easeInOutCubic(progress)) gives more cinematic acceleration at the cost of one more function to reason about when tuning timings.

Pin/scrub vs reveal

There are two families of scroll animation, and choosing the wrong one causes most of the pain:

  • Pin + scrub — hold a section fixed on screen while the story advances, and tie the animation directly to the scrollbar so it plays forward and backward as the user scrolls. Best for a hero moment that carries a whole pitch, or a product teardown.
  • Reveal — trigger a self-contained animation once when an element enters the viewport (a fade-up, a model settling into place), then leave it alone. Best for content further down the page where a pinned takeover would feel heavy.

A common, robust approach is one pinned scrub section near the top and reveal animations for everything below. Reserve pinning for the moments that earn it — pin every section and the page feels like a hijacked scroll. The AETumi 3D scroll collection leads with dedicated scrollytelling built on exactly this split, so you can see both patterns cooperating on one page rather than fighting each other.

GSAP ScrollTrigger + Three.js

You can wire scroll math by hand, but GSAP's ScrollTrigger handles the pinning, the progress math, and the scrub easing so you can focus on the scene. The clean pattern is to let ScrollTrigger own scroll progress and hand it to your Three.js state — never to let it touch the scene directly:

import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);

const state = { progress: 0 };

ScrollTrigger.create({
  trigger: '#scene',
  start: 'top top',
  end: '+=200%',      // section scroll length = 2 viewport heights
  pin: true,
  scrub: 1,           // 1s catch-up smoothing, not instant snapping
  onUpdate: (self) => { state.progress = self.progress; }
});

Explanation: pin: true fixes the #scene element while the user scrolls through the +=200% range, and onUpdate writes ScrollTrigger's own 0-to-1 self.progress into your shared state object. Note what this does not do: it never calls renderer.render(). Expected behavior: the section sticks to the viewport for two screen-heights of scrolling while state.progress climbs smoothly from 0 to 1. Trade-off: ScrollTrigger is ~40 KB gzipped and adds a dependency; for a single simple reveal, hand-written math is lighter, but once you need pinning and scrub easing the library pays for itself in edge cases handled (resize, refresh, nested triggers).

Decouple scroll from the render loop

The most common beginner mistake is calling renderer.render() from inside the scroll event. Scroll events fire in bursts, unaligned with frames, so you get redundant renders, jank, and torn animation. Instead, treat scroll as input and render on your own clock:

let current = 0;

function tick() {
  // ease current toward the scroll target for smooth catch-up
  current += (state.progress - current) * 0.08;

  mesh.rotation.y = current * Math.PI * 2;
  camera.position.z = 6 - current * 3;

  renderer.render(scene, camera);
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

The scroll handler (or ScrollTrigger) only writes state.progress; the rAF loop is the single place that renders. Explanation: the lerp current += (target - current) * 0.08 moves the rendered value 8% of the remaining distance each frame, producing the weighty catch-up that defines good scroll-driven 3D. Expected behavior: fast scrolling no longer snaps the model instantly — it glides to the new position over a few frames. Trade-off: a lower lerp factor feels more luxurious but adds latency; tune it to the content, and drop it toward 1.0 for anything the user needs immediate feedback on. This decoupling is demonstrated end-to-end in the threejs-scroll-animation repository.

React Three Fiber implementation

If your project is React or Next.js, React Three Fiber gives you the same architecture with hooks instead of manual loops. Drei's ScrollControls provides the scroll offset and useFrame is your render clock:

import { ScrollControls, useScroll } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import { useRef } from 'react';

function Model() {
  const ref = useRef();
  const scroll = useScroll();
  useFrame((state, delta) => {
    const p = scroll.offset;                  // 0 → 1 progress
    ref.current.rotation.y += (p * Math.PI * 2 - ref.current.rotation.y) * 0.08;
    state.camera.position.z = 6 - p * 3;
  });
  return <mesh ref={ref}>{/* geometry + material */}</mesh>;
}
// <ScrollControls pages={3}><Model /></ScrollControls>

Explanation: useScroll().offset is R3F's equivalent of the normalized progress value, and useFrame runs once per rendered frame — the same input/state/render separation, expressed declaratively. Expected behavior: wrapping <Model /> in <ScrollControls pages={3}> creates three pages of scroll that drive the rotation and dolly. Trade-off: R3F adds a reconciler and a steeper mental model than vanilla Three.js, but for teams already in React it removes a lot of imperative wiring; see the React Three Fiber fundamentals for when to choose it over raw Three.js.

Real product evidence

The demo below is a production scroll scene from the AETumi library, not a lab toy. Watch how the subject holds the center of the frame while the camera and lighting shift with the scrollbar — the motion reverses cleanly when you scroll back up, the visible signature of a scene driven by progress rather than a fired-once timeline. It proves three things: the scroll-to-state mapping is smooth across the full range, the lerp gives the motion weight without lag, and the scene holds a steady frame rate while the camera moves. This is the same pin-and-scrub pattern documented above, shipped as an editable template you own for life.

Performance

Scroll scenes are easy to build and easy to make heavy. The essentials:

  • Cap pixel ratio. renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) — rendering at native 3x on phones burns frames for no visible gain.
  • Pause offscreen. Use an IntersectionObserver to stop the rAF loop when the canvas leaves the viewport, and restart it on re-entry. There is no point rendering a scene nobody can see.
  • Throttle real work. The rAF loop already caps you at the display refresh; avoid adding extra per-scroll-event work on top of it.
  • Keep geometry honest. Compress models with Draco and textures with KTX2/Basis, and decimate anything the camera never gets close to.
  • Dispose on teardown. In single-page apps, dispose geometries, materials, and the renderer when the section unmounts to avoid leaks.
let running = true;
const io = new IntersectionObserver(([e]) => { running = e.isIntersecting; });
io.observe(renderer.domElement);

function tick() {
  requestAnimationFrame(tick);
  if (!running) return;            // skip all work while offscreen
  current += (state.progress - current) * 0.08;
  renderer.render(scene, camera);
}

Trade-off, stated honestly: scroll-driven 3D is a real performance budget, not a free effect. If a scene cannot hold a smooth frame rate on a mid-range phone, simplify the geometry or material before shipping — never ship a page that stutters to look impressive. Every scroll scene in the library ships with the pixel-ratio cap, offscreen pause, and reduced-motion fallback already wired in.

SEO for scroll-driven 3D

3D built on a canvas is invisible to crawlers, so the SEO rule is simple: never put content that needs to rank inside the WebGL scene. Headlines, body copy, links, and CTAs must live in real HTML behind or beside the canvas, with the 3D as an enhancement layer on top. Lazy-mount the scene after first paint so it does not block Largest Contentful Paint, and keep the initial payload small enough that Core Web Vitals stay green on mobile. Done this way, a scroll-driven page indexes exactly like a normal one — the crawler reads the DOM, the visitor gets the experience. The failure mode is baking marketing copy into a texture; that copy is unreadable to Google and to screen readers alike.

Accessibility

Motion tied to scroll can be uncomfortable or disorienting for some users, so a reduced-motion path is mandatory, not optional. Detect the preference and render a single static frame instead of the animated loop:

const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
  renderer.render(scene, camera);   // one static frame, no rAF loop
} else {
  requestAnimationFrame(tick);
}

Beyond that: keep all real content in the DOM so keyboards and screen readers work, never trap or hijack the scroll, and make sure any interactive control layered over the canvas has a visible focus state and sufficient contrast against the moving scene behind it. A well-built scroll experience is fully usable — just less cinematic — with the 3D turned off entirely.

Production trade-offs

Scroll-driven 3D buys memorability and comprehension at the cost of a persistent render budget, more code to maintain, and a hard dependency on WebGL being available and performant on the visitor's device. It increases the surface area for bugs — resize handling, mobile touch scrolling, back-button state — and it demands a fallback path you must build and test, not just intend. On text-first sites the maintenance cost rarely pays back. The honest position is that scroll-driven 3D is a specialist tool for a small number of high-impact moments, and that for most pages a well-shot video or a crisp static image communicates the same idea for a fraction of the budget.

When to use it

Use scroll-driven 3D when…Why it fits
A hero moment must carry the whole pitchPin + scrub turns one screen into a guided narrative
A product benefits from being inspected in spaceRotation and teardown explain form better than photos
You are choreographing copy and geometry togetherScroll is a shared timeline for content and scene
The brand is premium and expects a cinematic feelDeterministic motion reads as crafted, not gimmicky
You can commit to a mobile performance budgetThe effect only works if it stays smooth on real phones

When NOT to use it

Avoid scroll-driven 3D when…Use instead
The content is text-first (docs, blog, most B2B)Fast, flat HTML and CSS
A single clip conveys the whole ideaAn autoplay-muted, lazy-loaded video
The audience is on constrained devices or networksA static hero image with a poster fallback
The team can't maintain a WebGL codebaseA CSS or Lottie micro-interaction
You can't name what the 3rd dimension communicatesDon't spend the frame budget at all

Decision matrix

ApproachMotion controlPerformance costBest for
CSS scroll animationLow (transforms, opacity)Very lowReveals, parallax, simple depth
Lottie / SVG on scrollMedium (2D vector)LowIllustrated storytelling, icons
Three.js revealMedium (fire-once 3D)MediumA single 3D object entering view
Three.js pin + scrubHigh (full scene, reversible)HighHero teardowns, product configurators
WebGPU / custom shadersVery highHigh–very highBespoke effects at the edge of the platform

How AETumi approaches it

Expert Note — treat scroll progress as the only source of truth. The most stable scroll scenes we ship never store animation state anywhere except the derived progress value. Camera, rotation, and material are all pure functions of progress, which means the scene is identical whether the user scrolled there slowly, jumped with a scrollbar drag, or reloaded mid-page. If you find yourself accumulating rotation across frames with += on the target rather than the rendered value, that is the bug — recompute from progress every frame instead.

Expert Note — build the reduced-motion frame first, not last. A scroll scene that only looks right in motion has no honest fallback. We design the single static "hero frame" — the composition a reduced-motion user sees — before wiring any animation, then treat the motion as interpolation between that frame and the others. This guarantees the page is presentable with the loop disabled and forces a good default composition.

AETumi is an AI-native 3D web platform, and its scroll templates are built on precisely the input/state/render architecture in this article. Every template ships with full source, a capped pixel ratio, offscreen pausing, a reduced-motion path, and an AI build prompt you can hand to Claude Code, Cursor, or the AETumi MCP to restyle the scene section by section. You buy once and own it for life — Standard $19, Pro $39, Premium $99, and Full Stack $129 (full source plus the MCP workflow) — so the render-loop engineering is solved before you start on the idea.

GitHub and technical proof

The threejs-scroll-animation repository in the AETumi GitHub organization is a runnable reference for everything above. It uses Three.js r160 as native ES modules over an import map (no UMD bundle), so you can read the source without a build step, and it demonstrates the full decoupling: a ScrollTrigger instance that writes only to a state object, a single requestAnimationFrame loop that lerps and renders, a capped pixel ratio, and a prefers-reduced-motion branch. Its limitations are honest — it is a single-scene demonstrator, not a full site framework, and it assumes a modern WebGL-capable browser rather than a legacy shim. The performance notes call out the pixel-ratio cap and offscreen pause as the two changes that most affect mobile frame rate. Use it as a starting skeleton, then layer your own geometry and choreography on top.

How it works, step by stepAETumi technical diagram — How it works, step by stepDepth & 3D (Three.js / WebGL)Scroll choreography (pin, scrub, reveal)Motion & easing (GSAP)Restraint & pacing
How it works, step by step

FAQ

What is a Three.js scroll animation? It is a WebGL scene whose state — camera, rotation, materials — is driven by how far the user has scrolled, expressed as a 0-to-1 progress value, rather than by a time-based clock. Scrolling forward advances the scene and scrolling back rewinds it, so the frame you see is always a deterministic function of scroll position, which makes the effect both testable and straightforward to make accessible.

Should I use GSAP ScrollTrigger or write scroll math myself? ScrollTrigger is worth it once you need pinning and scrub easing — it handles the progress math, resize, and refresh edge cases that are tedious to get right by hand. For a single simple reveal, hand-written scroll math is lighter and avoids a dependency. Either way, keep it decoupled from your render loop by having it write only to a shared state variable. The scroll templates in the AETumi library use ScrollTrigger for exactly this reason, keeping it fully decoupled from the render loop.

Does scroll-driven 3D hurt performance? It can. Cap the pixel ratio, pause the render loop when the canvas is offscreen, compress geometry and textures, and test on a mid-range phone, not your dev machine. Done with a budget in mind it stays smooth; ignored, it drains frames and battery. If a scene cannot hold a steady frame rate on real mobile hardware, cut geometry or effects before shipping.

How do I make it accessible? Detect prefers-reduced-motion and render a single static frame instead of the animated loop for users who request reduced motion. Keep all real content in the DOM so screen readers and keyboards work, never hijack or trap the scroll, and give any control layered over the canvas a visible focus state. A good scroll page is fully usable with the 3D disabled.

Can I build a Three.js scroll animation in React? Yes. React Three Fiber with Drei's ScrollControls and useScroll gives you the same input/state/render architecture using useFrame as the render clock. It suits projects already in React or Next.js, while raw Three.js remains the lighter choice for a standalone scene. The library ships both so you can match your existing stack.

Conclusion

Scroll is the one interaction every visitor already performs, and a Three.js scroll animation turns it into a control surface for a 3D story. Map scroll progress to scene state, decouple the scroll input from a single requestAnimationFrame render loop, lerp toward the target for weight, pick pin/scrub or reveal deliberately, and hold a real performance, SEO, and accessibility budget. Get those right and a passive page becomes a guided experience. Browse the AETumi 3D scroll collection for production-ready scroll scenes you own for life, study the working code in threejs-scroll-animation, and see the bigger picture in cinematic website design. AETumi is an AI-native 3D web platform — its scroll templates ship with full source and AI build prompts, buy once own for life. Start with a template at aetumi.app/pricing and build your first scroll scene this week.

More from the AETumi library

Real, production-ready assets — preview the motion, grab the source.

Browse all 3D scroll templates →