“Solution” to Connect WebGL Visuals to Multiple DOM Elements with One Canvas - Without Scroll-Jacking
TL;DR: An imperfect solution. One that creates another issue, but it's a problem we can mitigate.
In the world of web development, it's basically gospel that scroll-jacking is bad. If you're a developer who hasn't worked much with WebGL or animation-heavy interactions, you might wonder why so many award-winning websites (like our own at Lusion) seem to always be scroll-jacked.
The truth is, aside from the aesthetic of smooth scrolling, scroll-jacking actually solves a key synchronization issue - especially on mobile. The problem? Native scrolling doesn’t run on the same thread as requestAnimationFrame (rAF). Since WebGL rendering relies on rAF to keep visuals smooth and efficient, this desync causes issues.
Let’s say you want to render a 3D box inside a DOM element - maybe a <div>. The obvious approach: create a WebGL canvas and place it inside that div. Done, right?
Not quite.
The problem is:
- You can’t have infinite WebGL contexts on a single page.
- Resources can’t be shared across different contexts. (👀 Looking at you, WebGPU…)
- You can't apply shader effect outside that DOM element area.
A better approach is to create a single fullscreen WebGL canvas, fixed to the viewport, and render everything there. Then, during each rAF, you get the bounding box of your DOM elements and scroll position (window.scrollY) to position your 3D objects accordingly.
But here comes the kicker:
If the scroll happens between two rAF calls, the canvas doesn’t get the updated scroll info in time - so your visuals start drifting, lagging behind where they’re meant to be.
This is why scroll-jacking became the go-to workaround. It gives you full control over the scroll timing, which helps ensure the WebGL visuals stay in sync with your DOM elements.
Here’s an idea that I haven’t seen many used - and it's super simple:
What if the WebGL canvas isn't fixed to the viewport?
Wait, what?
Yeah - instead of setting the canvas position: fixed and pinning it behind everything, let it scroll with the page. Set it to position: absolute, and during each rAF, offset the canvas to match the current scroll position.
At first glance, it sounds like the same thing. But here’s the difference:
If the scroll happens between two rAFs, the canvas will physically scroll with the page, keeping your 3D visuals attached to the DOM elements they’re linked to. No drift.
Pretty cool, right?
There is a catch: Since the canvas now scrolls, it may get clipped when parts of the viewport move outside the canvas bounds. You’ll notice some visual issues during fast scrolls.
But this can be mitigated:
- Simply add vertical padding to your canvas - render extra pixels offscreen. In our demo, we added 25% top and bottom padding to the canvas.
- Or, for better performance, render to a fullscreen framebuffer and apply edge blending or fading to mask the overflow areas.
Of course, this isn't free. You'll be rendering more pixels, which can be a concern depending on performance requirements. But in many use cases, it’s a totally acceptable tradeoff. The bottm-line to us is that drifting is visually distributing but clipping isn't, so you can based on your needed to apply different fixes to this solution.
This approach won’t magically fix the rAF desync problem - but it offers a neat workaround for keeping WebGL visuals visually in sync with DOM elements, without scroll-jacking.
It's not perfect, but sometimes, "good enough" is what gets the job done.
Hope you find this useful!
Clone the repo and run it locally:
# Clone the repo
git clone https://github.com/lusionltd/WebGL-Scroll-Sync
cd WebGL-Scroll-Sync
# Install dependencies
npm install
# Run the development server
npm run dev
# Build for production
npm run buildExplore the source code in the /src folder to see how the WebGL canvas is managed and synced with DOM elements.
We don't expect any contribution to this repo as it is just a demo.
MIT License.