Scroll Driven 3D Sites with three.js, GSAP and Lenis
How to build smooth scroll effects with Claude Code: picking the right tool, one animation loop, pinned 3D scenes, performance rules and reduced motion
Scroll driven sites look like expensive agency work: a product that turns as you scroll, text that assembles itself, a 3D scene that moves with your thumb. Underneath, almost all of it is one number. How far through a section the reader has scrolled, from 0 to 1, mapped to something visual. Once you see it that way, the whole genre gets a lot less mysterious.
I build scroll driven 3D sites for my own products with three.js, GSAP and Lenis, and Claude Code writes most of the wiring. This guide is the setup I actually use, plus the performance and accessibility work that separates a smooth site from one that makes phones hot.
Pick the lightest tool that does the job
Most scroll effects fall into one of four buckets, and each has a right tool.
- Simple reveals (fade in, slide up as a section enters): CSS scroll driven animations with
animation-timeline: view(). No JavaScript at all. Support is not universal yet, so treat it as an enhancement and make sure the content looks fine without it. - Pinned, scrubbed sequences (a section sticks while things animate): GSAP ScrollTrigger. GSAP and all its plugins have been free, including for commercial use, since 2025.
- Product spins and teardowns: an image sequence painted on a canvas, where scroll picks the frame. This is the classic Apple product page technique, explained well in this CSS-Tricks walkthrough.
- Real 3D: three.js, with scroll progress driving the camera, materials or model animation.
Lenis sits on top of any of these and smooths the scroll itself, so scrubbed motion feels continuous instead of stepping with each wheel tick.
The base setup
Ask Claude Code for a clean starting point before any effect. In an empty folder:
Create a Vite project with vanilla JS. Install three, gsap and lenis.
Set up one requestAnimationFrame loop only: GSAP's ticker drives Lenis and
ScrollTrigger, and the three.js render happens inside the same ticker.
Add a full screen fixed canvas behind the page and five tall placeholder
sections with ids. Import lenis/dist/lenis.css. No effects yet.
Run the dev server and give me the URL.
The important piece is that Lenis and ScrollTrigger share one clock. This is the pattern from the Lenis docs:
import Lenis from 'lenis';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis();
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
Two loops fighting each other is the most common reason a scroll site feels jittery, and it is easy to end up there when you add effects one prompt at a time.
Three patterns worth learning
A pinned 3D scene
Pin a section, and map its progress to the camera path or a model's animation. Scrub makes it follow the scroll instead of playing on its own clock.
Pin #product for 300% of the viewport height. Build a GSAP timeline with
scrollTrigger { trigger: '#product', start: 'top top', end: '+=300%',
scrub: 1, pin: true }. On that timeline, move the three.js camera along
three positions and rotate the model a full turn. Keep all tween targets
as plain JS values that the render loop reads.
An image sequence
Export 60 to 150 frames of a spin or an exploded view, compress them as WebP or AVIF, and preload them. Then map progress to a frame index and draw only when the index changes. Fewer frames with good easing beats hundreds of frames nobody's phone can hold in memory.
Text that reacts
Headlines that split and settle as they enter, or a line of text that fills with color as you scroll through it. For simple versions, CSS with view() is enough. For per word or per character effects, GSAP's SplitText plugin handles the splitting without breaking screen readers when you configure it properly.
Performance, the part that decides everything
A scroll effect that drops frames feels worse than no effect. These are the rules I hand Claude every time:
- Animate transform and opacity. Animating width, top or box shadow forces layout or expensive repaints on every frame.
- Cap the pixel ratio.
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)). A 3x phone screen rendering full resolution WebGL is a battery drain for no visible gain. - Render only when needed. Stop the three.js loop when the canvas is off screen (use an IntersectionObserver) and when the tab is hidden.
- Ship light assets. Compress glTF models with Draco or Meshopt and textures with KTX2. three.js supports all three through GLTFLoader and KTX2Loader.
- Test on a mid range phone. A fast laptop hides everything. Record a trace in the browser's Performance panel and look for long frames while scrolling.
After a few effects are in, ask Claude to audit instead of add:
Audit this site for scroll performance. List every place that animates a
layout property, every requestAnimationFrame loop, every ScrollTrigger that
could be created twice, and any WebGL work that runs while off screen.
Fix them one at a time and tell me what changed.
Reduced motion is not optional
Big scroll driven motion can make some people genuinely unwell. The operating system setting reaches your page as prefers-reduced-motion, and respecting it takes very little work:
const mm = gsap.matchMedia();
mm.add('(prefers-reduced-motion: no-preference)', () => {
// create pinned timelines and scrubbed effects here
});
mm.add('(prefers-reduced-motion: reduce)', () => {
// show the final state of each scene, no pinning, simple fades at most
});
Lenis already turns off smoothing when reduced motion is on. Beyond that, never change how far one scroll gesture moves the page, keep every piece of text readable without JavaScript, and make sure pinned sections do not trap keyboard users.
Start with one pinned section and one reveal on a real page of yours. Get it smooth on a phone and respectful of reduced motion before adding a second effect. That order is what makes the final site feel crafted rather than busy.