CASE STUDIESPersonal project
A portrait made of particles: a 3D scene that never blocks the offer
The hero of this site turns a photo into a GPU-driven particle field that reacts to the cursor, the gyroscope and touch. The condition: the headline and call to action must be in the first byte of HTML, and the scene must fit a phone’s budget.
- When
- 2025–2026
- Role
- Author: design, shaders, motion, performance
- Stack
- Next.jsReactTypeScriptThree.jsGLSLGSAPTailwindCSS

8 000
bio text particles along Bézier curves
4
custom GLSL shaders
1
clock for scroll and animation
Problem
The site of a one-person software house has to sell services, so the headline and buttons must be in server-rendered HTML before any JavaScript loads. At the same time I wanted the hero to show what I can do in 3D: a portrait scattered into particles, bio text flowing towards the face, a reaction to the cursor and taps. The two goals clash easily: a heavy canvas delays the content, and a particle field computed on the CPU drops frames on phones.
A second problem appeared after adding smooth scrolling: Lenis and ScrollTrigger had separate clocks, so scroll-driven animations landed one frame behind the page position. A third: after a route change ScrollTrigger restored the previous page’s scroll position, and the transition curtain revealed the new page halfway down.
Approach
Content and scene are separated at the React level: the hero renders the headline, pitch and CTAs on the server, and the scene is a dynamically loaded chunk without SSR that “must never gate the offer copy”. If WebGL fails, an ErrorBoundary shows text instead of a void.
The portrait becomes particles in the browser: the image lands on an offscreen canvas scaled to 220 pixels wide, and only a pixel with alpha above a threshold and luminance below a threshold becomes a particle. The result is an InstancedBufferGeometry with one quad and per-instance attributes, drawn in multiply mode over a plain textured plane. The bio text is 8 000 particles, each with a start point, two control points and an end; the vertex shader computes the curve and rotation, and the CPU only updates time.
Solution
- Tap shockwave: pointerdown on the canvas pushes a pulse (NDC coordinates plus time) into a ring of two, fires navigator.vibrate(12) and a CSS ripple. The pulse envelope (attack smoothstep to 0.08, release from 0.5 to 1) exists in one version in JS and in GLSL, so the camera, portrait and stream breathe in the same rhythm.
- The tap position is stored in the object’s local space, not in NDC, because as the camera drifted the wave slid along with it. An off-screen sentinel prevents “phantom” repulsion before the first tap.
- Camera: damped cursor following on desktop (maath damp3), gyroscope input on phones with a neutral tilt of 45° and a 45° range, asking for the iOS permission on the first touch. A refusal leaves the camera centred.
- Render budget: dpr capped at 1, no antialiasing, frustum culling off on both particle systems, and the render loop stopped through an IntersectionObserver once the hero leaves the screen.
- One clock: Lenis ticked from gsap.ticker and reporting scroll to ScrollTrigger, with lagSmoothing(0). Scrubbed tweens land in the same frame as the scroll position.
- Transition curtain: after a route change, ScrollTrigger’s scroll memory is cleared, refreshed and followed by an instant scrollTo, and scrolling is blocked through wheel/touchmove instead of overflow:hidden, because the vanishing scrollbar shifted the page sideways. An emergency reveal after 10 seconds.
- prefers-reduced-motion switches off Lenis, scroll velocity, the curtain and haptics, and speeds ScrollTrigger tweens up five times.
Result
The offer is in the first byte of HTML, the scene arrives after it and goes quiet when out of view. The GPU computes the portrait and text particles, and every effect (wave, camera, scroll) runs on one clock. The site is a proving ground: every new motion effect lands here first.
What I learned
- A shockwave has to be anchored in the world, not on the screen. Any camera-dependent effect without its own frame of reference will float.
- Two sources of time always mean a one-frame lag somewhere in the system. Lenis, GSAP and ScrollTrigger must tick from one place.