Key answer: A GSAP Three.js scroll effect binds the scroll position of the page to the state of a WebGL scene, so scrolling scrubs a camera move, a model rotation, or a timeline of reveals instead of just moving the page down. GSAP's ScrollTrigger measures scroll progress and pinning; Three.js renders the scene; the clean way to connect them is to let ScrollTrigger write a single normalized progress value and let one requestAnimationFrame loop read that value and draw — never to call the renderer from inside a scroll or tween callback. It works because a scroll scene's state is a pure function of one number between 0 and 1, which GSAP is exceptionally good at producing smoothly, with pinning, scrubbing, and snapping handled for you. Use it for a signature hero or product reveal; keep the render loop decoupled and cap the pixel ratio, and always provide a reduced-motion path.
Table of contents
- What a GSAP Three.js scroll effect is
- What you'll learn
- Why combine GSAP with Three.js
- Architecture: progress in, frames out
- Setup: ScrollTrigger and a scene
- Scrub a camera with a GSAP timeline
- Pin the canvas while the timeline plays
- The decoupled render loop
- Snap to sections
- Reduced motion and cleanup
- Real product evidence
- Performance
- Accessibility
- Production trade-offs
- When to use GSAP + Three.js scroll
- When NOT to
- Decision matrix
- How AETumi approaches it
- GitHub and technical proof
- FAQ
- Related AETumi resources
- Conclusion
What a GSAP Three.js scroll effect is
A GSAP Three.js scroll effect is a page where scrolling controls a 3D scene rather than merely scrolling past it. As the visitor scrolls a section, a camera glides through a product, a model rotates and disassembles, or a sequence of elements fades in on a choreographed timeline — all driven by scroll position. Two libraries do the work. GSAP, through its ScrollTrigger plugin, is responsible for measuring where the user is in a scroll range, pinning the section in place while the effect plays, and producing a smooth, eased progress value. Three.js is responsible for the WebGL scene itself: the camera, the model, the materials, and the render.
The reason the pairing is so common is that each library is best-in-class at exactly the half of the problem the other does not solve. Writing scroll math by hand — clamping, easing, pinning, handling resize and refresh — is tedious and error-prone, and GSAP ScrollTrigger has already solved it robustly across browsers. Three.js, meanwhile, owns the rendering. The entire craft of such a build is connecting them correctly, and the correct connection is narrower than most tutorials suggest: GSAP produces one number, and your render loop consumes it.
What you'll learn
- How GSAP ScrollTrigger and Three.js divide the work between scroll and render
- The single architecture that keeps the two libraries from fighting each other
- How to scrub a camera along a GSAP timeline tied to scroll progress
- How to pin a canvas section so the scene plays in place
- Why you must never render from a tween callback, and what to do instead
- How to add section snapping and a
prefers-reduced-motionfallback cleanly
Why combine GSAP with Three.js
You could compute scroll progress yourself with a scroll listener and some arithmetic, and for the simplest linear scrub that is fine. GSAP earns its place the moment the effect grows past that: when you need the section pinned for a fixed scroll distance, when you want several tweens sequenced on a timeline, when the motion should ease rather than track linearly, or when you want the page to snap to the nearest section. ScrollTrigger handles pinning, refresh on resize, and the messy edge cases of scroll measurement that hand-rolled code gets wrong, and it does so with an API designed for exactly this.
The combination also scales in maintainability. A GSAP timeline is a readable, declarative description of what happens across the scroll — at 20% the camera pulls back, at 60% the model splits, at 90% the label fades in — which is far easier to reason about and adjust than a wall of if (progress > 0.6) branches. That readability is why the GSAP Three.js scroll pattern has become the default for premium scroll experiences, and why the companion build guide uses the same skeleton this article optimizes and connects.
Architecture: progress in, frames out
There is one architecture that keeps GSAP and Three.js cooperating, and every stable build uses it. GSAP owns input: ScrollTrigger converts scroll position into a normalized progress value and, if you tween a proxy object, eases it. Three.js owns output: one requestAnimationFrame loop reads the current progress and draws the scene. The two never touch each other directly — GSAP writes a number into a small shared object, and the render loop reads it. That indirection is the whole secret.
The failure mode this prevents is subtle but universal. If you call renderer.render() inside a ScrollTrigger onUpdate or a tween's callback, you tie your draw rate to GSAP's callback rate instead of the display's refresh, producing redundant draws, dropped frames, and torn motion during fast scrolls. Keep the boundary clean — progress in through GSAP, frames out through one rAF loop — and the scene stays smooth no matter how the user scrolls. This is the same input/state/render model behind every scene in the AETumi 3D scroll collection.
Setup: ScrollTrigger and a scene
Context. Start by registering ScrollTrigger and creating a shared state object that both libraries agree on. The scene setup is standard Three.js r160; the only unusual part is that nothing renders yet.
import * as THREE from 'three';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
const state = { progress: 0 }; // the one shared number
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 0, 6);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // cap first, always
renderer.setSize(innerWidth, innerHeight);
document.querySelector('#stage').appendChild(renderer.domElement);
Explanation: state.progress is the contract between GSAP and Three.js; the pixel ratio is capped at 2 up front because it is the highest-leverage performance decision for any scroll scene. Expected behavior: a mounted, empty scene and a registered ScrollTrigger, ready to be connected. Trade-off: keeping a plain object as shared state instead of a framework store is deliberate — it is the lightest possible bridge and adds no re-render overhead.
Scrub a camera with a GSAP timeline
Context. The core move is scrubbing: tying a GSAP timeline to scroll so that scrolling plays it forward and scrolling up plays it back. You tween a proxy — state.progress — not the scene directly, so the render loop stays the only thing that reads it.
const tl = gsap.timeline({
scrollTrigger: {
trigger: '#stage',
start: 'top top',
end: '+=2000', // 2000px of scroll drives the whole timeline
scrub: 1, // ease scroll→timeline by ~1s; true = linear
},
});
tl.to(state, { progress: 1, ease: 'none' }); // just advance the number
Explanation: ScrollTrigger maps the 2000px scroll range onto the timeline, and scrub: 1 smooths the mapping so quick flicks glide instead of snapping; the tween's only job is to move state.progress from 0 to 1. Expected behavior: scrolling the section advances progress smoothly, and reversing scroll runs it back. Trade-off: scrub: 1 adds a second of catch-up weight that feels premium but lags precise input; use scrub: true for a tight, immediate mapping when the scene demands exactness.
Pin the canvas while the timeline plays
Context. A scrub is far more effective when the section is pinned — the canvas holds still on screen while the scroll budget drives the animation, so the user feels they are scrubbing the scene rather than scrolling past it. ScrollTrigger pins with one property.
ScrollTrigger.create({
trigger: '#stage',
start: 'top top',
end: '+=2000',
pin: true, // hold #stage fixed for 2000px of scroll
pinSpacing: true, // reserve the space so layout doesn't jump
});
Explanation: pin: true fixes the section in the viewport for the scroll distance, and pinSpacing: true inserts padding so the rest of the page flows correctly after the pinned range. Expected behavior: the 3D canvas stays centered and still while the scrollbar drives the timeline, then releases and normal scrolling resumes. Trade-off: pinning changes document layout and can conflict with position: sticky or transformed ancestors, so pin a clean, top-level section; if you hit jitter, prefer a CSS position: sticky canvas over transform-based pinning.
The decoupled render loop
Context. This is the part most tutorials get wrong. The render loop is the only place that reads state.progress and draws. GSAP never renders; it only moves the number.
let current = 0;
function tick() {
requestAnimationFrame(tick);
current += (state.progress - current) * 0.1; // extra smoothing
camera.position.z = 6 - current * 3; // dolly in as we scroll
camera.lookAt(0, 0, 0);
renderer.render(scene, camera);
}
requestAnimationFrame(tick);
Explanation: the loop eases its own current toward the GSAP-driven state.progress and maps it onto the camera, so there is exactly one draw per display frame regardless of how often ScrollTrigger updates. Expected behavior: buttery camera motion that tracks scroll and holds frame rate during fast flicks. Trade-off: you now have two easing stages — GSAP's scrub and the loop's lerp — which can feel floaty if both are strong; if motion drifts, set scrub: true and keep the lerp, or keep scrub: 1 and raise the lerp factor toward 1.
Snap to sections
Context. For a multi-beat scroll story, snapping pulls the scroll to rest on each beat instead of stopping mid-transition. ScrollTrigger snaps to timeline positions with no extra loop.
const tl = gsap.timeline({
scrollTrigger: {
trigger: '#stage',
start: 'top top',
end: '+=3000',
scrub: 1,
snap: {
snapTo: [0, 0.33, 0.66, 1], // four beats
duration: 0.4,
ease: 'power1.inOut',
},
},
});
Explanation: snapTo lists normalized timeline positions; when the user stops scrolling, ScrollTrigger animates to the nearest one over 0.4s. Expected behavior: the scene settles cleanly on each defined beat rather than freezing in a half-finished transition. Trade-off: snapping fights users who want to scroll freely and can feel heavy-handed on long ranges, so reserve it for short, deliberate stories of three to five beats, not an entire page.
Reduced motion and cleanup
Context. Scroll-hijacking motion must be optional, and pinned ScrollTriggers must be torn down on navigation in a single-page app or they leak. GSAP's matchMedia handles both.
const mm = gsap.matchMedia();
mm.add('(prefers-reduced-motion: no-preference)', () => {
const tl = buildScrollTimeline(); // your pinned, scrubbed timeline
return () => tl.scrollTrigger?.kill(); // cleanup on unmount / media change
});
// reduced-motion users get no timeline at all — render one static frame instead
renderer.render(scene, camera);
Explanation: gsap.matchMedia only builds the scroll timeline when the user has no motion preference and returns a cleanup function that kills the ScrollTrigger when the component unmounts or the query changes. Expected behavior: motion-sensitive users see a single static frame with no pinning or scrubbing, and route changes leave no orphaned triggers. Trade-off: you maintain two paths — animated and static — but the static branch is trivial and doubles as your no-JavaScript baseline.
Real product evidence
The demo below is a production scroll scene from the AETumi library built on exactly this GSAP Three.js scroll pattern: a pinned canvas, a scrubbed GSAP timeline advancing a single progress value, and a decoupled render loop dollying the camera through a product. Watch how the motion eases into each beat and reverses cleanly on scroll-up without tearing — that smoothness under fast input is the visible signature of the decoupled architecture, where GSAP owns the number and one render loop owns the frame. It proves the article's central point: the pairing is stable not because of clever tweens but because the boundary between the two libraries is kept clean. It ships as editable source you own for life, so the timeline is yours to re-choreograph.
Performance
The performance rules for a scroll-driven WebGL scene are the same regardless of the driver, and GSAP does not change them. Cap devicePixelRatio at 2 first — it recovers more frames than any tween tuning. Keep the render loop as the sole renderer so scroll event volume never multiplies your draws. Pause the loop when the canvas leaves the viewport with an IntersectionObserver, since a pinned section can still be scrolled past in a long page. Compress models with Draco and textures with KTX2 so the first frame arrives fast. Profile with renderer.info to confirm a low, flat draw-call count, and always test on a mid-range phone, because pinning plus a scrubbed camera is exactly where a weak GPU shows strain first.
Accessibility
Scroll-driven 3D intercepts the most fundamental interaction on the web, so it carries real accessibility weight. Honor prefers-reduced-motion with the static-frame path above, never trap the scroll — the page must always continue past the pinned section — and keep all real content and controls in the DOM so keyboard and screen-reader users are unaffected by the canvas. Snapping deserves particular care: aggressive snap can strand users who navigate by keyboard or who scroll in large jumps, so keep snap ranges short and test tabbing through the section. A well-built scroll scene is fully usable with the animation disabled and never fights the person trying to read it.
Production trade-offs
This pattern adds a second library, a pinning layer that alters document layout, a build pipeline for compressed assets, and two easing stages that must be tuned to agree. That is meaningful complexity, and it is only worth it when the scroll moment genuinely carries the experience. For a text-first page, the honest recommendation is to skip it entirely: a static hero image or a short muted video communicates most product ideas with none of the pinning, cleanup, or performance risk. Reach for GSAP and Three.js together when the choreography is the pitch, and keep it to one deliberate section rather than letting it govern the whole page.
When to use GSAP + Three.js scroll
| Use it when… | Why it fits |
|---|---|
| A hero or product reveal must feel choreographed | GSAP timelines make sequenced beats readable |
| The section should pin while the scene plays | ScrollTrigger pinning is robust and cross-browser |
| Motion should ease, not track linearly | scrub plus a loop lerp gives premium weight |
| The story has a few distinct beats | Snapping settles cleanly on each one |
| You'll adjust the choreography over time | A declarative timeline is easy to re-tune |
When NOT to
| Skip it when… | Use instead |
|---|---|
| The page is text-first (docs, blog, B2B) | Fast, flat HTML and CSS |
| A single frame tells the whole story | A sharp static hero image |
| Motion is the point but 3D isn't | A short autoplay-muted video |
| The scrub is a simple linear track | A plain scroll listener and rAF loop |
| The team can't maintain WebGL + GSAP | A CSS scroll-driven animation |
Decision matrix
| Need | Use GSAP feature | Owned by |
|---|---|---|
| Measure scroll progress | ScrollTrigger start/end | GSAP |
| Ease scroll into the scene | scrub: 1 + loop lerp | GSAP + render loop |
| Hold the section on screen | pin: true | GSAP |
| Sequence multiple beats | gsap.timeline() | GSAP |
| Settle on beats | snap: { snapTo } | GSAP |
| Draw the frame | renderer.render() in rAF | Three.js |
How AETumi approaches it
Expert Note — never render from a tween callback. The single most common GSAP Three.js scroll bug is calling renderer.render() inside onUpdate or a tween. It ties your draw rate to GSAP's callback rate, not the display refresh, and produces the exact tearing people blame on "3D being heavy." Let GSAP move a number and let one requestAnimationFrame loop draw; the jank disappears.
Expert Note — pin a clean top-level section. ScrollTrigger's pinning manipulates layout, and it conflicts with transformed ancestors, overflow: hidden parents, and nested position: sticky. If pinning jitters, do not fight it with more GSAP config — move the pinned canvas to a simple top-level section, or switch to a CSS position: sticky canvas and let ScrollTrigger drive only the progress value.
AETumi is an AI-native 3D web platform, and its scroll templates ship with this GSAP Three.js scroll architecture already assembled — a pinned section, a scrubbed timeline writing one progress value, a decoupled and capped render loop, and a reduced-motion path with cleanup wired in. Because each template comes with an AI build prompt for Claude Code, Cursor, or the AETumi MCP, you can re-choreograph the timeline by describing the new beats and reviewing the diff rather than rebuilding the scroll plumbing from zero. You buy once and own the source for life — Standard $19, Pro $39, Premium $99, and Full Stack $129 (full source plus the AETumi MCP workflow) — so the fragile boundary between GSAP and Three.js is solved once and reusable across every project. The scroll templates and their live previews sit in the catalog at AETumi.app.
GitHub and technical proof
The threejs-scroll-animation repository in the AETumi GitHub organization is a runnable reference for the pattern in this guide. It loads Three.js r160 as native ES modules over an import map with no UMD bundle and no build step, and it demonstrates the exact split described here: ScrollTrigger writes a single normalized progress value, one requestAnimationFrame loop reads it and dollies the camera, the pixel ratio is capped at 2, and a prefers-reduced-motion branch renders a static frame. Its limitations are stated plainly in the README: it ships a single-scene scrub rather than a full multi-section story, its snapping example is illustrative rather than tuned for production stories, and it assumes a modern WebGL-capable browser with GSAP loaded. The performance notes call out the pixel-ratio cap and the decoupled loop as the two decisions that most affect smoothness. Use it as a skeleton, layer your own timeline beats on top, and profile against your own geometry.
FAQ
Do I need GSAP for a Three.js scroll effect? Not for the simplest linear scrub — a plain scroll listener writing a normalized progress value into one render loop is enough. You need GSAP the moment the effect grows: pinning a section for a fixed scroll distance, sequencing several beats on a timeline, easing scroll into motion, or snapping to sections. ScrollTrigger handles pinning, resize refresh, and scroll-measurement edge cases that hand-rolled code gets wrong, which is why it is the default for anything beyond a basic track.
Why does my GSAP Three.js scroll scene tear or stutter? Almost always because you render from inside a GSAP callback. Calling renderer.render() in an onUpdate or tween ties your draw rate to GSAP's callback rate rather than the display refresh, causing redundant draws and torn motion. Fix it by letting GSAP move only a progress number and letting one requestAnimationFrame loop read that number and draw. Also cap devicePixelRatio at 2, which resolves most remaining mobile jank on its own.
Should I use ScrollTrigger's scrub or ease in my render loop? Use both, but tune them so they agree. GSAP's scrub eases scroll into the timeline, and a lerp in the render loop eases the scene toward the current progress. Two strong easings feel floaty. A reliable combination is scrub: 1 with a moderate loop lerp for premium weight, or scrub: true with the loop lerp when you want a tighter, more responsive mapping. Test on real hardware and adjust one stage at a time.
How do I make scroll-driven 3D accessible? Honor prefers-reduced-motion by rendering a single static frame instead of the pinned, scrubbed timeline, and build that branch with gsap.matchMedia so it also cleans up triggers on unmount. Never trap the scroll — the page must always continue past the pinned section — keep all content and controls in the DOM, and keep any snapping ranges short so keyboard users are not stranded. The scene should be fully usable with the animation off.
Does pinning break my page layout? It can, because ScrollTrigger's pinning manipulates document flow and conflicts with transformed ancestors, overflow: hidden parents, and nested sticky elements. Keep pinSpacing: true so the space is reserved, pin a clean top-level section rather than a deeply nested one, and if you still see jitter, switch to a CSS position: sticky canvas and let ScrollTrigger drive only the progress value while CSS handles the hold.
Related AETumi resources
- AETumi 3D scroll collection — production scroll templates built on this pattern
- Three.js fundamentals — the rendering library under every scroll scene
- React Three Fiber — the React binding, with GSAP integration patterns
- WebGL fundamentals — the layer Three.js wraps, for deeper optimization
- 3D scroll animation websites — how to build the scene this guide connects to GSAP
Conclusion
A GSAP Three.js scroll effect is only as good as the boundary between the two libraries. Let GSAP ScrollTrigger own the input — measuring scroll, pinning the section, easing progress, snapping to beats — and let one requestAnimationFrame loop own the output, reading a single progress value and drawing the scene. Never render from a tween callback, cap the pixel ratio at 2, pin a clean section, and ship a reduced-motion frame, and you get choreographed 3D that stays smooth under any scroll speed. Browse the AETumi 3D scroll collection for templates built on exactly this architecture, study the working code in threejs-scroll-animation, and see how the scene itself is assembled in 3D scroll animation websites. 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 ship a scroll story that glides.
More from the AETumi library
Real, production-ready assets — preview the motion, grab the source.

