There is a specific kind of bug that makes you feel gaslit by your own code: the animation runs, the tween fires, onComplete reports back, and absolutely nothing moves on screen. I hit one of those while building a WebGL hero gallery for a high end company profile site. It took me an embarrassingly long time to accept that the problem was not in the animation at all.
The symptom: the tween runs, the mesh does not move
The hero was a gallery strip: a row of textured planes sliding left and right with scroll, rendered with Three.js. On first load, all the planes were supposed to sit stacked in the center and then fan out sideways one by one. I wrote it with gsap.to() against mesh.position, which on paper is about three lines of code.
What actually happened was different. From the very first frame the planes were already sitting in their final spread out layout, twitched for an instant, then went perfectly still. The fan out from the center was never visible at all, so it looked like the tween had never been called.
I checked the usual suspects:
- Console clean, no errors.
- Every tween's
onCompletefired, so GSAP was definitely running the animation to the end. - Logging
mesh.position.xfrom insideonUpdateshowed the number easing smoothly from 0 to its target. Exactly what I asked for.
So GSAP was doing its job perfectly. The number was changing. The canvas was just not drawing that number.
The root cause: two writers, one property
The gallery's render loop is scroll driven. Every frame it recalculates each mesh's position from the current scroll state:
render() {
this.meshes.forEach((mesh, i) => {
const b = this.getOffset(i); // offset derived from scroll state
mesh.position.x = b * this.spacing;
});
this.renderer.render(this.scene, this.camera);
requestAnimationFrame(this.render);
}Read that assignment again. mesh.position.x = b * this.spacing does not nudge the position. It declares it, unconditionally, sixty times a second.
So the order of events inside a single frame goes: GSAP writes its tweened value to mesh.position.x, the render loop overwrites it with the scroll derived number, and only then does the renderer draw. What gets drawn is always the loop's value. GSAP's value lives for a few microseconds and dies before anyone can see it.
This is not a GSAP bug and it is not a Three.js bug. Both do exactly what they are told. The flaw was in my design: the render loop had no concept of another animation currently owning positions. It assumed it was the only writer in the building.
And that problem is far more common than it looks. Any time you have a requestAnimationFrame loop that assigns a property from some source of truth, and you then tween that same property, you are not animating anything. You are entering a race you are guaranteed to lose, because the loop always writes last before the frame is drawn.
The fix: one boolean that decides who owns the property
The solution turned out to be simple, and I was not the first person to land on it. When I dug into the bundle of the reference site the design was based on, the pattern was already there: a single boolean called isIntro that decides who currently owns position.
While isIntro is true, the render loop skips the position calculation entirely. GSAP becomes the only writer. Once the intro finishes, the flag flips off and control goes back to scroll.
render() {
this.meshes.forEach((mesh, i) => {
const b = this.getOffset(i);
if (!this.isIntro) {
mesh.position.x = b * this.spacing; // only once the intro is done
}
const mat = mesh.material;
mat.uniforms.uTime.value = this.clock.getElapsedTime();
mat.uniforms.uVelocity.value = this.isIntro ? 0 : shaderVel;
});
this.renderer.render(this.scene, this.camera);
requestAnimationFrame(this.render);
}Note that only the position line is skipped. Shader uniforms keep updating, because the reveal effect and the time based motion in the shader still need to run during the intro.
The one uniform that needs special handling is velocity. It is derived from the frame to frame delta of the scroll position, and during the intro the scroll position does not move at all while the meshes travel a long way under tween control. Left on its normal calculation, velocity produces nonsense and the shader smears distortion across the exact moment you most want to look clean. So during the intro it is forced to 0.
Handing control back to scroll
The rest is knowing when to flip isIntro off. The fan out itself looks like this:
playIntro() {
const total = this.meshes.length;
let done = 0;
this.meshes.forEach((mesh, i) => {
let f = i - this.centerIndex;
// normalise for a strip that wraps forever
f = ((f % total) + total) % total;
if (f > total / 2) f -= total;
gsap.to(mesh.position, {
x: f * this.spacing,
duration: 1.2,
ease: 'expo.out',
delay: Math.abs(f) * 0.05,
onComplete: () => {
if (++done === total) {
this.isIntro = false; // control returns to the render loop
}
},
});
});
}Two details here that I learned the hard way.
Count the completions, do not guess which tween finishes last. The delay is based on Math.abs(f), so completion order follows distance from center, not array index. The tween belonging to the last element in the array may well finish first. A plain counter incremented in every onComplete removes the guesswork, and the flag only flips after the genuinely final tween lands.
The modulo normalisation is mandatory for an infinite strip. Without those two lines, planes with indices far from center get animated toward enormous offsets, flying across the viewport before settling. The modulo wraps the index into the full range, and subtracting total turns anything past the halfway mark into a shorter negative distance. The result is that each plane travels the short way around instead of orbiting the whole strip.
Replaying the intro without corrupting state
Because the intro also gets reused, for example when the gallery resets, I split it into its own function:
replayIntro() {
this.isIntro = true; // lock first
this.meshes.forEach((mesh) => {
mesh.position.x = 0;
mesh.material.uniforms.uReveal.value = 0;
});
this.playIntro();
}The order matters, but what matters more is that all three happen in the same tick. Lock with isIntro = true first, then reset the positions, then call playIntro(). As long as it stays synchronous like that, no frame can slip in between, because the render loop only runs once this code has finished. What burns you is deferring the lock: setting the flag inside a callback, after an await, or on the next tick. Let a single frame render while the loop still believes it owns position and the reset you just wrote gets stomped by the scroll value, so the fan out starts from the wrong layout instead of the stacked center. That bug shows up only sometimes, and that is the most miserable kind to chase.
What I took away
- When two systems write the same property every frame, the winner is not the more correct one. It is whichever writes last before the frame draws, and here that was always the render loop.
- A
requestAnimationFrameloop that assigns values unconditionally needs some way to release ownership. One boolean is enough, no state machine required. - When you skip part of a loop, audit the derived values that depended on it. Velocity computed from a position delta becomes meaningless the moment something else moves that position.
- A tween that runs but never shows almost always means something is overwriting it, not that the tween failed. Log the value from
onUpdateto prove that early, before you start rewriting animation config.