I Built 15 WebGL Animations. The Hard Part Wasn’t the WebGL.
What I learned about render loops, WebGL context limits, real physics, performance, and reduced motion while turning a collection of experiments into a production-ready animation library.
There is a particular kind of link a client sends you.
No brief.
No Figma file.
Just a URL and four words:
“Can we do that?”
On the other end is a site where the cursor drags liquid across a photograph, where a headline’s letters lean away from your pointer like iron filings, where scrolling tears a product apart into its components.
It looks like magic.
It is not magic.
It is a signed-distance field, a spring integrator, and somebody’s very long afternoon.
I spent that afternoon fifteen times.
Then I threw the afternoons away and kept the system.
This is what the system looks like — and the four engineering decisions that turned “a folder of cool demos” into something you can actually hand to a client.
The Rule That Made This Possible: Lowest-Complexity Technology Wins
The instinct, when you know WebGL, is to reach for WebGL.
It is almost always wrong.
Take the image trail effect: copies of a photo stacked behind your cursor as it moves.
The lazy version is fifteen DOM nodes with decreasing opacity.
It looks like a PowerPoint transition.
The over-engineered version is a render-target ping-pong with a velocity buffer.
The right version is Canvas 2D with actual perspective maths.
Each trail instance has a real Z depth. Its corners get rotated in 3D, divided by the focal plane, and the resulting affine basis is written straight into setTransform().
Depth drives scale, draw order, and atmospheric tint.
The result is about 90 lines, runs at 60fps on integrated graphics, and — crucially — doesn’t consume a WebGL context.
Because a WebGL context is one of the scarcest resources on the page.
The same logic killed three shader passes.
The holographic cards use thin-film interference maths in CSS instead of a refraction shader:
2 · n · d · cos(θ)
mapped through the visible spectrum into gradient stops.
The liquid button uses the blur() → contrast() metaball trick on a small canvas because that is real viscous merging.
WebGL appears exactly where WebGL is the only honest answer:
Displacement fields. Dissolves. Portals. Tunnels. Aurora.
Decision #1: There Is One Render Loop, and It Is Not Yours
Fifteen animations means fifteen requestAnimationFrame loops if you build them the obvious way.
Fifteen loops means fifteen independent clock reads.
Fifteen delta-time calculations.
And, more importantly, fifteen things continuing to run after the user has scrolled past them.
So the library owns a single ticker.
Effects subscribe to it.
The loop cancels itself when there are no subscribers.
function loop(now: number) {
const dt = Math.min((now – last) / 1000, MAX_DT);
last = now;
for (const fn of Array.from(subscribers)) {
fn(dt, elapsed, frame);
}
if (subscribers.size > 0) {
rafId = requestAnimationFrame(loop);
} else {
running = false;
}
}
Two details matter more than they look.
Delta-time needs a ceiling
A backgrounded browser tab can return with a four-second dt.
A stiff spring integrated over four seconds doesn’t politely ease back into place.
It launches itself into NaN.
So the delta is clamped.
Subscribers need to be copied
An effect is allowed to unsubscribe during its own tick.
That’s exactly what happens when an effect tears itself down mid-frame.
The pointer gets the same treatment.
One passive pointermove listener publishes:
position
smoothed velocity
normalized energy
It is reference-counted, so fifteen magnetic effects still cost one listener.
The sampler switches itself off when nothing is reading it.
Decision #2: Budget Your WebGL Contexts Like Memory
Here is the bug that ends careers.
Browsers can silently drop older WebGL contexts when too many are alive.
You don’t necessarily get a useful error.
Your hero canvas simply goes black.
Usually on the pages with the most content, on the machines with the most going on, weeks after launch.
A library with fifteen effects — several of them WebGL — can hit that ceiling surprisingly quickly.
So the engine treats contexts as a scarce resource with an explicit budget.
The rules are simple:
Four surfaces may be live at once.
Each surface reports a visibility score.
When a fifth requests a context, the least-visible surface is suspended.
Suspended contexts are properly destroyed with WEBGL_lose_context.
When the surface becomes visible again, it re-initializes.
Effects are initialized lazily when they approach the viewport.
releaseOnExit tears them down when they leave it.
This means a page can mount all fifteen effects while remaining inside the context budget.
The demo even ships with a HUD showing the live count so you can watch the system work.
Live demo: https://motionforge-lyart.vercel.app/
Decision #3: Physics Is Not a Metaphor
Two effects in the library are physical simulations.
Both fall apart if you fake them.
The navigation is a Verlet rope.
Each item is a body with a position and previous position. Velocity is implicit.
Bodies are pinned to their rest anchors by constraints and coupled to their neighbours through distance constraints.
The constraints are relaxed over five iterations per step.
The cursor applies a repulsion field with a squared falloff.
Dragging pins a body to the pointer.
Releasing it writes the pointer’s measured velocity into px and py.
That’s how you throw it.
Nothing is tweened.
There is no:
ease: “elastic”
anywhere near it.
And you can feel the difference.
Elastic easing is a curve pretending to have mass.
The core is simply:
integrate(points, dt, gravity, drag);
satisfy(constraints, 5);
The typography works the same way.
Every glyph owns a critically damped spring.
Inside the influence radius, cursor attraction falls off with distance squared, while the glyph inherits a slice of pointer velocity as skew.
Because the springs are per-glyph and slightly detuned, a fast swipe sends a wave through the word instead of moving the entire word as one block.
That wave only exists because the numbers are real.
Write on Medium
The same principle applies to the shaders.
The liquid reveal isn’t a blurred circle mask.
It uses nine spring-chained metaballs merged with a polynomial smooth-min. Its boundary normal is sampled again to create the refractive rim.
The dissolve isn’t a simple wipe either.
An FBM field determines the order in which pixels disappear. Surviving pixels near the burn front are displaced, stretched through a second turbulence pass, desaturated, and surrounded by hashed ember cells that continue moving after the original pixel is gone.
Decision #4: Reduced Motion Is a State, Not a Checkbox
prefers-reduced-motion is where many motion libraries get quietly dishonest.
They disable an opacity transition and call it accessible.
That isn’t enough.
Every effect in this library ships with a documented fallback that preserves the content and the API.
For example:
The liquid reveal displays the photograph in full colour with no shader or canvas.
The 3D carousel becomes a scroll-snap strip using the same panels.
The scroll tunnel becomes seven concentric CSS frames.
The physics navigation becomes a static row using the same real elements.
The morph renders its first shape with the geometry intact.
The glitch effect becomes a single 60ms flash on hover instead of looping automatically.
The media query is also observed live.
Changing the operating-system preference reinitializes the page without requiring a reload.
The demo includes a switch that forces the same code path.
Because if you cannot see your fallbacks, you don’t have fallbacks.
You have intentions.
What the Fifteen Actually Are
Each effect ships as its own folder containing an index.ts and a detailed README covering:
installation
configuration
advanced configuration
React usage
vanilla usage
mobile behaviour
accessibility
performance
troubleshooting
Every effect starts with the same base configuration:
target
intensity
duration
mobileMode
respectReducedMotion
lazy
releaseOnExit
And every effect follows the same lifecycle contract:
const fx = createPortalTransition({
target: el,
images,
twist: 2.4
});
fx.setOptions({ duration: 2 });
fx.open(clientX, clientY);
fx.destroy();
That last method is probably the least exciting part of the API.
It’s also one of the most important.
destroy() removes the nodes, listeners, textures, and contexts.
In an SPA, that’s the difference between a portfolio site and a memory leak.
The Part I’d Hand to an AI
Every effect also ships with a customization prompt.
Not a generic:
“Make this animation cooler.”
A real engineering brief.
The prompt tells a coding agent which architecture must remain intact:
lifecycle wrapper
shared ticker
WebGL context budget
reduced-motion branch
The agent can change:
colours
intensity
timing
visual treatment
page integration
without quietly replacing the underlying physics with:
transition: all 0.3s;
That distinction matters.
AI is very good at changing what you can see.
It is much less trustworthy when you ask it to preserve the engineering decisions you cannot see.
The customization prompt exists to protect those decisions.
What I’d Do Differently
Measure earlier
The WebGL context budget exists because I found the black-canvas bug the hard way.
If you’re building something with more than three WebGL contexts, build the budget first.
Beware filter costs
SVG feTurbulence can look incredible on a headline.
It can also become catastrophic when applied to a large amount of text every frame.
Never read layout inside a tick
Every getBoundingClientRect() inside a per-frame callback can trigger expensive layout work.
Measure on resize.
Cache the values.
Interpolate.
Design teardown with the effect
Don’t bolt cleanup on at the end.
Lifecycle management should be part of the effect’s architecture from the beginning.
See It Move
The whole system runs directly in the browser.
Fifteen live specimens.
Each one can be opened and reconfigured in real time.
The HUD reports:
FPS
live WebGL contexts
ticker subscribers
So the performance claims aren’t just theoretical.
You can actually watch the system work.
→ Live Demo: https://motionforge-lyart.vercel.app/
If you build websites where motion is part of the argument rather than decoration, I packaged the complete system:
15 effects + engine + documentation + customization prompts + demo source.
→ MotionForge on Gumroad: https://boukataya.gumroad.com/l/cqzizd
Built with TypeScript, WebGL/GLSL, Three.js, GSAP ScrollTrigger, Lenis, and an unreasonable amount of coffee.
Questions in the responses — I answer all of them.