Engineering
How we build a scroll-driven 3D story website
React Three Fiber, GSAP ScrollTrigger and Lenis, wired so the scroll bar is the timeline. Here’s how we keep it fast, accessible and honest about the hardware it runs on.
The Web Pundit Team · Engineering ·
A 3D story site is a short film the visitor directs with their thumb. Scroll down, the camera moves. Scroll back, it rewinds. Done well, it’s the most memorable page a brand can own. Done badly, it’s a loading spinner with a bounce rate attached.
This is how we build ours. It’s the same approach behind the homepage you may have just scrolled through, so every rule here is one we live with.
The stack, and why each piece is there
- React Three Fiber (R3F) renders the three.js scene as React components, so the scene lives in the same codebase, type system and review process as the rest of the site.
- GSAP ScrollTrigger turns scroll position into a progress value from 0 to 1 and drives the camera, lights and captions from it.
- Lenis smooths wheel input so the scene doesn’t judder on mice with coarse scroll steps, while keeping native scrolling underneath.
- Next.js serves the page, with the text rendered on the server so it exists before any JavaScript runs.
Rule one: scroll maps 1:1
We set `scrub: true` on the ScrollTrigger. Scroll position is the timeline. No easing lag, no snapping to chapters, no speed limiter that makes the page feel like it’s wading through syrup.
Why so strict? Because the moment the page disagrees with the user’s hand, it feels broken. Snapping hijacks control. A smoothing value on the scrub makes the camera trail behind the scroll bar, which feels laggy on a trackpad. Lenis already handles input smoothing once. Doing it twice is how sites end up feeling drunk.
Lenis and ScrollTrigger need to share one clock. We drive Lenis from GSAP’s ticker and tell ScrollTrigger to update on every Lenis scroll event:
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);Rule two: sticky, not pinned
ScrollTrigger can pin an element, which works by wrapping it and adding padding to make room. That extra spacing gets calculated in JavaScript after load, and anything computed late can shift the layout. Layout shift is what Cumulative Layout Shift (CLS) measures, and Google counts it.
So we let CSS do it. The story section is a tall container, several viewports high, and the canvas sits inside it with `position: sticky` and `top: 0` at full viewport height. The browser knows the height of everything on first paint. Nothing moves when the scripts arrive. ScrollTrigger only reads progress across the tall container; it never touches layout.
<section className="relative h-[600vh]">
<div className="sticky top-0 h-svh">
<StoryCanvas />
</div>
</section>Rule three: the headline is the LCP, not the canvas
Largest Contentful Paint (LCP) measures when the biggest visible element finishes rendering. If that element is a WebGL canvas waiting on a megabyte of three.js and a model file, your LCP is terrible. If it’s server-rendered text, it can be nearly instant.
Our first caption is the page’s `<h1>`. It’s in the HTML. It’s styled with CSS that ships with the page. It’s never hidden with an initial `opacity: 0` waiting for an animation to reveal it, because an invisible element can’t count as painted. The 3D scene arrives behind it, as a guest.
Rule four: three.js loads after first paint
three.js is not small, and R3F plus helpers adds more. None of it is needed to read the headline, so none of it belongs in the initial bundle. We load the canvas component with a dynamic import, client-only, and only after the page has painted and the browser has a quiet moment.
"use client";
import dynamic from "next/dynamic";
const StoryCanvas = dynamic(() => import("./StoryCanvas"), {
ssr: false,
loading: () => <StoryPoster />,
});While the chunk downloads, a lightweight SVG poster of the opening shot sits in place, same size, same position. When the canvas is ready it fades in over the poster. No jump, no blank rectangle.
Rule five: respect the hardware
A flagship laptop and a three-year-old budget Android both deserve a working site. They don’t deserve the same render settings. We use the `PerformanceMonitor` component from drei, which watches the frame rate and calls back when it climbs or drops.
- We start at a sensible middle tier rather than assuming the best.
- On decline, we lower the device pixel ratio first, because it’s the cheapest big win. Then we drop post-processing, then shadow quality.
- On incline, we step back up, one tier at a time.
- If the monitor flip-flops between states, we settle on the lower tier and stop adjusting. A stable 30 frames beats a jittery 60.
We also pause rendering when the canvas leaves the viewport and when the tab is hidden. A scene nobody can see shouldn’t be burning someone’s battery.
Rule six: reduced motion gets stills, not a lesser site
Some people set their operating system to reduce motion because camera movement makes them unwell. For them, a flying camera isn’t delight. It’s a reason to leave.
We check `prefers-reduced-motion` with `gsap.matchMedia()`. When it’s set, there’s no scrubbed camera. Each beat of the story becomes a still frame with its caption, in normal document flow. Same words, same order, same calls to action. The story is intact; only the motion is gone.
Rule seven: no WebGL, no problem
WebGL can be missing or disabled: old devices, locked-down corporate machines, some privacy setups, the odd driver bug. Before mounting the canvas we try to create a WebGL context. If that fails, the visitor gets the same poster-and-captions layout as reduced motion. They never see an error, and they never see a blank box.
Keeping it maintainable
Story sites rot when the numbers are scattered. We keep every camera position and target in one shots file, and every colour in one palette file. Changing the third beat means editing one object, not hunting through ten components.
We also avoid per-frame React state. Setting state on every scroll tick re-renders components sixty times a second. Instead, the scroll progress lives in a ref, and R3F’s `useFrame` reads it and writes directly to the camera. React handles structure; the render loop handles motion.
The verdict
A 3D story site isn’t a performance problem by nature. It becomes one when the scene is treated as the page instead of a layer on top of it. Ship the words first. Load the spectacle second. Scale it to the device. Give everyone a version that works. Then let the camera fly.

