April 30, 2026 · Joyco Engineering
Syncing WebGL with the DOM
How we keep a single fullscreen canvas perfectly aligned with scrollable content using lightweight DOM trackers.
Mixing WebGL and HTML usually means choosing between many small canvases or one big canvas. We choose one big canvas — and then use empty DOM elements as “trackers” that tell the renderer where each piece of GL content should live on screen.
The block above is just a muted div with a fixed aspect ratio. On mount, the GL layer reads its bounding rect, projects it into NDC, and renders inside those bounds — every frame, every resize, every scroll. The browser handles layout, the GPU handles pixels, neither has to know about the other.
In code that pairing reads as a single component: <TrackedBox>renders the placeholder div in the article, and any R3F children passed to it are tunneled into the canvas and positioned by the box’s rect. One React tree, one canvas, no portals between renderers.
<TrackedBox className="aspect-video" label="…">
<TrackingCube color="#3b82f6" />
</TrackedBox>Pinning content while the page scrolls
Trackers don’t have to move with the page. A tall outer section paired with a stickychild gives us a region that pins to the viewport while surrounding prose scrolls past it. The renderer reads the sticky element’s rect just like any other tracker — it stays still on screen, so the GL content stays still too, even as the scroll position advances.
One subtle bit: a sticky tracker only flips the canvas into fixed mode once its sticky ancestor is actually pinned, not the moment the box becomes visible. We watch the sticky element’s live top against its computed top: on every scroll tick and only flip when they match. Otherwise the canvas would swap positioning context a frame too early and the GL content would jump.
This is where the single-canvas approach pays off. We can drive the shader inside the pinned rect from scroll progress through the outer section — fade between scenes, advance a timeline, parallax a camera — without ever creating a second WebGL context. The sticky child decides where it lives on screen; scroll progress decides what it shows.
Two ways to anchor a canvas
A single fullscreen canvas can be parked on the page in two ways, and this demo supports both at runtime. Open the debug panel with Alt + D and find the Dynamic Canvas folder — the default mode dropdown flips between them live.
In fixed mode the canvas is position: fixed in the viewport. It never moves with the page, so DOM trackers are projected relative to the current scroll offset every frame. In absolute mode the canvas is taller than the viewport (with padding above and below) and rides the page on the GPU compositor; a per-frame translate3d nudges it back over the visible region.
Mode isn’t a global toggle — each tracker raises a request, and a small resolver picks a winner: any 'fixed' request → fixed, else any 'absolute' request → absolute, else fall back to a default. That way an absolute scroller and a sticky tracker can coexist on the same page and the canvas adopts whichever positioning the loudest visible tracker needs.
Both modes are correct — the choice is about which kind of drift you’d rather absorb. Fixed never drifts on scroll (the canvas is part of the viewport, not the page) but anything you want to scroll with the page has to be moved by your code against the live scroll value. If you ship a smooth- scroll library like Lenis that owns the scroll value, this is the natural fit. Absolute rides the compositor — the canvas and the DOM scroll together as one GPU layer, so static GL content is pinned for free. The vertical padding (25% above and below by default) hides the brief edge clipping during fast flicks on mobile.
Flip the dropdown a few times while scrolling. With the debug grid on, the mode change should be invisible — same content, same position, different anchoring strategy underneath.