Building a Scroll-Driven 3D Portfolio with React Three Fiber and GSAP
I recently rebuilt my portfolio around one idea: a glowing 3D “ember orb” that lives behind the page and travels with you as you scroll — weaving side to side, diving away while you read, and rising back up like a sunset behind the contact section. This post covers the full stack behind it and the problems I hit on the way.
The stack
Next.js 15 with React 19, React Three Fiber v9 for the WebGL scene (v9 is the React 19-compatible major), GSAP 3 with ScrollTrigger for scroll animations, and Lenis for smooth scrolling. Since GSAP 3.13, all the formerly-paid plugins — including SplitText — are free, which changes the calculus a lot: character-level text reveals used to be the main reason people paid for Club GreenSock.
Wiring Lenis into ScrollTrigger
Lenis hijacks wheel input and animates the native scroll position, which means ScrollTrigger needs to be told about every scroll frame and both need to share one ticker. The canonical wiring is small:
const lenis = new Lenis({ lerp: 0.1, anchors: true })
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => lenis.raf(time * 1000))
gsap.ticker.lagSmoothing(0)One detail worth stealing: I skip Lenis entirely when the user has prefers-reduced-motion set, and every GSAP effect checks the same media query. Accessible motion isn't a nice-to-have — some people get physically ill from scroll-jacked pages.
The ember orb
The orb itself is surprisingly little code: an icosahedron with drei's MeshDistortMaterial gives you an organic, constantly morphing blob, and a Bloom pass makes the emissive color actually glow. A second, larger icosahedron rendered as a faint wireframe adds structure around the soft shape.
<mesh>
<icosahedronGeometry args={[1.7, 48]} />
<MeshDistortMaterial
color='#241512'
emissive='#ff5c1f'
emissiveIntensity={0.42}
roughness={0.18}
metalness={0.85}
distort={0.42}
speed={1.6}
/>
</mesh>Making it travel down the page
The canvas is a fixed, pointer-events-none layer behind the whole site. Every frame, I convert the scroll position into overall page progress (0 at the top, 1 at the footer) and map that progress to a path. Cosine and sine curves give you a serpentine sweep for free, and ending the cosine at a zero crossing means the orb finishes dead-center:
const p = window.scrollY / maxScroll // 0 → 1
// ends centered: cos(2.5π) === 0
const x = baseX * Math.cos(p * Math.PI * 2.5)
// weaves, then sinks below the fold at the end
const y = Math.sin(p * Math.PI * 3) - 2.1 * p * p
group.position.x = THREE.MathUtils.damp(group.position.x, x, 2.5, delta)
group.position.y = THREE.MathUtils.damp(group.position.y, y, 2.5, delta)The important part is THREE.MathUtils.damp — instead of setting positions directly, everything eases toward its target, so the orb trails your scrolling with momentum rather than snapping. It reads as alive instead of attached.
Readability is a feature
A glowing 1000-pixel blob behind body text is a readability disaster. Three fixes worked together: a scroll-scrubbed tween dims the whole canvas to 30% opacity while you're in the content sections (and brings it back for the finale), the glass cards got a more opaque background, and I bumped the muted text color's lightness. If you build something like this, budget as much time for legibility as for the effect itself.
The SSR flash
The nastiest bug: on reload, the server-rendered hero painted fully visible, then GSAP hydrated, hid everything, and played the intro — a flash of content that vanishes and re-animates. The fix is to hide the hero in CSS from the very first byte, then let GSAP un-hide it in the same layout effect that builds the timeline:
@media (prefers-reduced-motion: no-preference) {
.hero-content { visibility: hidden; }
}
// in the component, before the timeline:
gsap.set('.hero-content', { autoAlpha: 1 })Scoping the CSS to no-preference matters: reduced-motion users skip GSAP entirely, so for them the CSS never hides anything and the content just renders.
Performance notes
WebGL on a portfolio is a first-impression tax, so: cap the device pixel ratio at 1.75, skip the Bloom pass and cut the particle count on mobile, and load the entire Three.js bundle lazily with dynamic(() => import(...), { ssr: false }) so the page is readable before the scene exists. The main page stays fully static — Next.js prerenders everything, and the orb is progressive enhancement on top.
Takeaways
The whole effect is maybe 300 lines. The libraries have gotten so good that the hard part isn't the 3D — it's restraint: dimming your effect so people can read, respecting reduced motion, and keeping the first paint fast. If you want to see it live, it's running on this site.