All posts

One Canvas, six stops: a scroll choreography architecture

July 10, 20262 min read

How many WebGL Canvases does this site have? One. The hero portrait, the skills field, project previews, the backdrop and the dust — they all live inside a single R3F Canvas mounted once at layout level. This post covers the architecture behind that decision.

Why one Canvas?

A Canvas per section looks clean at first: every component manages its own scene. In practice every Canvas is a separate WebGL context — separate memory, separately compiled shaders, context limits on mobile and white flashes between sections. With one persistent Canvas the scene never dies; sections only change the scene's state.

Scroll flows from a single source

The chain is short and one-directional: Lenis smooths the scroll, ScrollTrigger syncs to Lenis, everything feeds off those two.

lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));

No component listens to native scroll events. Scroll progress is normalized (0..1) and written to shared state; the canvas side reads it every frame. DOM and WebGL look at the same number, so there is no sync drift.

Camera stops are data, not components

Where the camera goes is written in no component. choreography.ts holds a list of stops:

export const cameraStops: CameraStop[] = [
  { at: 0.0, position: [0, 0, 6],      accent: "#58a6ff" }, // hero
  { at: 0.2, position: [0.7, -2.6, 6.5], accent: "#7c6cff" }, // about
  // ...
];

sampleChoreography(progress) interpolates smoothly between two stops; the camera rig only calls this sampler. Adding a section = adding a stop to the list. The sampler is length-agnostic, so no code changes.

Scroll windows

Every effect has a "window": the portrait decode lives between 0.10–0.19, the skills field in its own range. The windows live in the same config file. Each effect component computes its own intensity with smoothstep(start, end, progress) — sections know nothing about each other, yet they all dance on the same scale.

Lessons

  • When choreography is data, rehearsal is cheap: tuning stops is a config change, not a code change; a trial-and-error loop takes seconds.
  • The R3F trap — uniforms don't update through the memoized object: textures and values must always bind through materialRef.current.uniforms. I learned this lesson three times.
  • No allocations inside useFrame: vectors and colors are allocated once outside and reused every frame; GC pauses are the number-one enemy of scroll smoothness.
0%