
Make It Feel Like Something
I'd rebuilt my portfolio a few times and it always landed in the same place — a page with sections, some scroll animations, a list of projects. Readable, forgettable. I wanted this one to have a physical quality to it, where moving through the page felt like moving through a space rather than reading a document.
The 3D planet in the hero was the first decision. Not because it's flashy but because it immediately tells you something is different here before you've read a single word. Everything else in the design had to earn its place relative to that opening.
3D Up Front, GSAP the Rest of the Way
The hero section is a React Three Fiber canvas — Planet.glb loaded with useGLTF, a Float wrapper for the idle bob, ambient lighting, and four Lightformer reflections giving it a soft studio look. Camera sits at [0,0,-10] with a narrow 17.5 fov that keeps the planet feeling close.
Everything below the hero is standard HTML and Tailwind, animated entirely with GSAP ScrollTrigger. ServiceSummary has four text blocks linked to scroll via xPercent — they slide past each other in opposite directions as you move through the section. Services cards come in from y:200 with a stagger. The About photo wipes in via clip-path. Works entries reveal from y:100. The Contact marquee spins faster when you scroll faster, because Lenis passes its scroll velocity to a GSAP Observer.
Section by Section
Each section was planned with its own animation before any code was written. The rule was that every transition had to feel motivated — not just 'things move in when you scroll', but a specific directional logic that matches how the content reads.
React Three Fiber canvas: Planet.glb, Float wrapper, ambientLight, 4 Lightformer circles. Camera at [0,0,-10], fov 17.5.
4 text blocks with xPercent values (20, -30, 100, -100) scrubbed to scroll progress — slide past each other in opposite directions.
4 cards (FullStack, DevOps, Security, Web & Mobile) entering from y:200 with 0.1s stagger on ScrollTrigger enter.
Section scales up from 0.95 on scroll-enter. Profile photo reveals via clip-path wipe.
Project entries from y:100 with 0.3s stagger. Hover shows a floating preview that follows the cursor. Mouse-enter expands a clip-path on the item.
Social links from y:100 with stagger. Marquee speed tied to Lenis scroll velocity via GSAP Observer.
Under the Hood
Planet.glb loaded with useGLTF and wrapped in Drei's Float. Environment IBL with four custom Lightformers for the reflections. Shadows enabled on the canvas.
ServiceSummary uses scrub-linked xPercent. Everything else uses enter-triggered from/to tweens with stagger. Each section owns its own timeline.
ReactLenis wraps the whole app. Scroll velocity is read by a GSAP Observer on the Contact marquee — the faster you scroll, the faster the text spins.
Reusable components that split text into per-line spans and animate them in from y:100 (body lines) or y:200 + opacity:0 (headers) on ScrollTrigger.
What Made It Hard
- Planet.glb has three separate meshes — two spheres and a ring — each with its own material. GSAP had to target their positions and rotations independently, which meant careful useRef work inside the R3F component to get handles on each mesh.
- Lenis and GSAP ScrollTrigger don't talk to each other by default. ScrollTrigger reads window.scrollY; Lenis has its own virtual scroll position. Fixing it required ScrollTrigger.scrollerProxy() to reroute ScrollTrigger's scroll reads through Lenis.
- The cursor-following preview on Works items needs to feel smooth but responsive. Too much lerp and it trails sluggishly. Too little and it feels mechanical. Getting that balance right took a lot of tuning of the lerp factor across different mouse speeds.
- The loading screen gates the page reveal until the GLTF is ready, using useProgress from @react-three/drei. Without this, the hero section would flash an empty canvas before the planet model finished loading.