The worst bugs are not the ones that explode. They are the ones that pretend to succeed. GSAP reports the tween ran. The console is clean. The timeline completes right on schedule. And on screen, the element has not moved at all.
I ran into the sneakiest version of that while polishing a preloader on a company profile site. The preloader had a small crossfade in it: the mark (the symbol on its own) fading out while the lockup (the full logo with wordmark) faded in. Straightforward. Two tweens, one timeline.
Except the mark never actually left. It sat there as a ghost element, stacked on top of the lockup that had already appeared, and only vanished abruptly when the preloader itself was hidden at the very end.
The symptom: a tween that "succeeds" and changes nothing
The crossfade code was as plain as it gets:
const tl = gsap.timeline();
tl.to(mark, { opacity: 0, scale: 0.8, duration: 0.7, ease: "power2.in" }, 0)
.to(lockup, { opacity: 1, duration: 0.9, ease: "power2.out" }, 0.5);The lockup came in correctly. The mark did not. And here is what burned my time: everything I checked came back clean.
markwas notnull, the selector matched.- The timeline ran,
onCompletefired. - No
overwritewas killing the tween. - In DevTools, the element's
styleattribute genuinely changed every frame. Theopacitynumber counted down from 1 to 0, exactly as requested.
That last detail is what finally stopped me. The inline style was there. The value was correct. And the element still rendered fully opaque. So GSAP was not failing to write. Something was outranking what it wrote.
The root cause: CSS animations live in their own cascade origin
The day before, while building the preloader itself, I had added a subtle breathing effect to the mark so it would not feel dead while assets loaded. One line of CSS that was no longer on my mind when I wrote the crossfade:
.preloader__mark {
animation: logoBreath 2.4s ease-in-out infinite;
}
@keyframes logoBreath {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.55;
transform: scale(0.985);
}
}Look at which properties those keyframes touch: opacity and transform. Precisely the two properties GSAP was tweening.
This is the part people get wrong, myself included. We are trained to treat inline style as the final word, because in a specificity fight it beats classes, ids, everything. But CSS animations do not compete on specificity at all. They get their own cascade origin, and that origin sits above every normal author declaration, inline style included.
The relevant slice of the cascade, from loser to winner:
- Normal author declarations, including the element's
style=attribute - CSS animation declarations
!importantauthor declarations- CSS transition declarations
So for as long as logoBreath kept looping, the browser rewrote that element's opacity and transform from the keyframes on every single frame. GSAP wrote opacity: 0.4 into inline style, the browser read it, said "sorry, there is a running animation on this property," and used the keyframe value instead. Next frame, same story. Seven hundred milliseconds of tug of war that CSS won every time.
And because logoBreath was infinite, there was never a moment where the animation ended and handed control back. The mark stayed visible, kept breathing, while the lockup appeared behind it. That was the ghost.
This is a different animal from a tween being clobbered by a requestAnimationFrame loop. That fight happens in JavaScript and you can catch it by logging the value. Here the JavaScript won, the value landed in the DOM correctly, and what discarded it was a cascade layer that shows up in no log anywhere.
The fix: kill the animation first, tween second
The rule turns out to be simple once you know the cause. Never tween a property that a CSS animation currently owns. Release ownership first, then hand it to GSAP.
function releaseFromCss(el) {
el.style.animation = "none";
// force a reflow so the browser registers the change
// before a tween in the same frame starts writing
void el.offsetWidth;
}
releaseFromCss(mark);
tl.to(mark, { opacity: 0, scale: 0.8, duration: 0.7, ease: "power2.in" });If the animation is attached through a separate class, say .is-breathing, dropping the class works just as well and reads cleaner:
mark.classList.remove("is-breathing");The method matters less than the order: animation off first, tween after.
Why animation-play-state: paused does not solve it
My first instinct was to pause the animation rather than remove it. That does not work, and the reason makes sense in hindsight.
Pausing only freezes the animation's clock. The animation is still active, still occupying its cascade origin, and still applying keyframe values at whatever time position it stopped on. All I achieved was a ghost that stopped breathing. To actually return control to inline style, the animation has to be removed, not paused.
Stitching the seam so it does not jump
Because logoBreath moves between opacity: 1 and 0.55, killing it mid cycle can snap the element back to its base value right before the tween starts. Over a 0.7 second fade, that snap is visible.
The fix is to read the current computed values first and use them as the tween's starting point:
const computed = getComputedStyle(mark);
const startOpacity = parseFloat(computed.opacity);
const startScale = new DOMMatrix(computed.transform).a;
mark.style.animation = "none";
void mark.offsetWidth;
gsap.fromTo(
mark,
{ opacity: startOpacity, scale: startScale },
{ opacity: 0, scale: 0.8, duration: 0.7, ease: "power2.in" }
);Now the tween begins from the exact visual state the visitor was looking at, and the handoff is invisible.
The quick check I now run every time
Whenever a tween "runs but does nothing," my first question is no longer about GSAP. I ask the element directly whether a CSS animation is holding it:
const running = getComputedStyle(el).animationName;
if (running !== "none") {
console.warn("This element has an active CSS animation:", running);
}One line, and it instantly separates two failure modes that look identical from the outside: GSAP not writing, versus GSAP writing and the write being thrown away.
Epilogue: I deleted the crossfade anyway
The honest ending: once the crossfade was correct and smooth, I watched it a few times on real devices and concluded the preloader felt better without that internal transition. Snapping from mark to lockup read as more decisive than two logos sliding past each other in front of the visitor for more than a second.
So the shipped version has no crossfade at all. The knowledge stuck around though, because the same pattern shows up any time an element with an idle animation needs to be taken over by JavaScript, which is often: pulsing buttons, bobbing scroll arrows, blinking badges.
What I took away
- Inline style wins on specificity, but loses to CSS animation declarations, because the two are competing in different cascade origins entirely.
infiniteanimations are the worst case. There is never a gap where they surrender the property back.animation-play-state: pausedfreezes the clock, it does not release ownership. To release it, setanimation: noneor drop the class.- Before handing a property to a tween, read its computed value so the animation to tween handoff does not jump.
- When inline style visibly updates in DevTools but the screen does not follow, stop suspecting your animation library. The problem is in the layer above it.