There is one kind of animation bug that makes you doubt everything: the animation runs perfectly, you just cannot see it. Every number is right, the timeline plays, onComplete fires on schedule, and the screen shows nothing. I hit one of those while building a shared element transition on the homepage of a high end company profile site.
Here is the setup. The homepage runs a WebGL gallery where each tile is really a Three.js plane with an image texture. Click a tile and the page opens a full screen overlay with a long form story, and the image you clicked is supposed to fly from its spot in the gallery to the hero position inside that overlay. I borrowed the pattern from a reference site that happens to be a SPA. Mine was not a SPA, just plain WordPress with no client side navigation, so the transition had to be assembled by hand.
The symptoms
Three things went wrong, and at the time all three looked like separate bugs:
- The morph was invisible. Sometimes it vanished completely, sometimes it flashed once on the final frame right before the overlay settled.
- Reaching for the GSAP Flip plugin gave me a
TypeErrorand a complaint about a missing source. - Closing the overlay was worse. The reverse morph drifted away from the tile position, and the hero image got clipped by the overlay scroller. I burned more than five iterations there and it still looked bad.
What kept me circling was that I spent the whole time fixing the numbers. I assumed the destination rect was wrong, or that my projection from WebGL space to screen pixels was off. So I logged everything mid tween:
gsap.to(clone, {
left: to.left,
top: to.top,
width: to.width,
height: to.height,
duration: 0.8,
ease: "power3.inOut",
onUpdate: () => {
const r = clone.getBoundingClientRect();
console.log(r.left, r.top, r.width, r.height);
},
});The numbers were clean. They travelled smoothly from the tile rect to the hero rect, exactly as asked. So position was never the problem. The element was in the right place, it simply was not visible.
Root cause: a parent's opacity caps every child
I had put the morphing clone inside the overlay, because that felt like the obvious home for it. It is part of the overlay transition, so it lives in the overlay.
The catch: the overlay itself enters with a CSS opacity transition from 0 to 1 over 0.52s. And opacity on a parent does not just tint that parent. It creates a compositing group. The whole subtree is rendered into a single layer first, and then that layer gets faded. The consequence is that no child can be more opaque than its parent. Setting opacity: 1 on the clone changes nothing, because that value is multiplied by a parent opacity still sitting at 0.1 or 0.3.
Now line up the timings. My morph ran for 0.8s while the overlay only reached full opacity at 0.52s. More than half of the morph happened while the element was nearly transparent. All the eye got was the tail end, when the clone was already close to its destination and barely moving. The brain reads that as a flicker, not as motion. The animation never failed. It was just playing inside a box that was being made see through.
As for Flip, it was the wrong tool on two levels. First, Flip works by measuring a DOM element in its start position and again in its end position. My source was not a DOM element at all, it was a plane inside a WebGL canvas. There was no node to measure, so the missing source complaint was fair. Second, when I went back and dug through the reference site I had copied, it turned out they never used Flip either. They ran a plain gsap.to on left, top, width, and height of a position: fixed element. I had added a plugin to a problem that did not need one.
The fix: append the clone to document.body
The core decision is one line, but that line is what actually killed the bug. The clone must not live inside the overlay. It has to be a floating element appended straight to document.body, outside every compositing group that is being animated.
function spawnFloatingClone(src, fromRect) {
const clone = document.createElement("img");
clone.src = src;
Object.assign(clone.style, {
position: "fixed",
left: `${fromRect.left}px`,
top: `${fromRect.top}px`,
width: `${fromRect.width}px`,
height: `${fromRect.height}px`,
objectFit: "cover",
zIndex: "9999",
pointerEvents: "none",
margin: "0",
});
document.body.appendChild(clone);
return clone;
}The source rect comes from WebGL, by projecting the plane's four corners from world space to NDC and then to client pixels. The destination rect is far easier, just a getBoundingClientRect() on the hero wrapper inside the overlay. With both rects in hand, the morph is an ordinary tween:
const from = planeRect(mesh); // project 4 plane corners -> screen px
const to = heroWrap.getBoundingClientRect();
const clone = spawnFloatingClone(textureSrc, from);
heroImg.style.opacity = "0"; // hide the real destination image first
gsap.to(clone, {
left: to.left,
top: to.top,
width: to.width,
height: to.height,
duration: 0.8,
ease: "power3.inOut",
onComplete: () => {
gsap.to(heroImg, {
opacity: 1,
duration: 0.2,
onComplete: () => clone.remove(),
});
},
});The order inside onComplete matters. Reveal the real image first, remove the clone after. Flip those two and you get one empty frame between them, which the eye catches instantly as a blink.
So that the overlay does not feel like it is waiting for the morph to finish, the content cascade runs in parallel rather than in sequence:
gsap.to("[data-overlay-reveal]", {
opacity: 1,
y: 0,
duration: 1.2,
ease: "power3.out",
delay: 0.1,
stagger: 0.05,
});
gsap.to("[data-overlay-wipe]", {
// from inset(0% 0% 100% 0%) set in CSS
clipPath: "inset(0%)",
duration: 1.2,
ease: "power3.out",
});The closing animation I ended up deleting
I never won the reverse morph. Two problems reinforced each other. The reverse destination is the tile position in the gallery, and that position moves, so the clone kept landing off target. And if the user had scrolled deep inside the overlay, the hero image was already out of the scroller viewport and clipped, so there was no sensible origin to morph back from.
After more than five iterations I stopped patching and changed the approach. Scroll the overlay back to the top first, with a duration that scales to how far the user had travelled, then slide the overlay up while fading it out, then replay the homepage entrance:
const dur = Math.max(0.25, Math.min(0.6, scroller.scrollTop / 2200));
gsap.to(scroller, {
scrollTo: { y: 0 },
duration: dur,
ease: "power2.inOut",
onComplete: () => {
gsap.to(overlay, {
y: "-100%",
opacity: 0,
duration: 0.6,
ease: "power3.in",
onComplete: playHomeEntrance,
});
},
});It is faster, it is consistent, and it never lands in the wrong place. Sometimes the best animation is the one you stop forcing to be symmetrical.
What I took away
- When
onUpdatereports the right numbers and the screen shows nothing, stop fixing the numbers. The problem is visibility, not position. - Opacity on a parent caps every descendant. No child can be more opaque than its parent, no matter what value you write on the child.
- An element morphing across containers belongs on
document.bodywithposition: fixed, free of any parent transform, opacity, or clipping. - Flip needs a DOM element as its source. If the origin is a WebGL object, compute the rect yourself and tween plain
left,top,width,height. - Before adding a plugin because a reference site looks sophisticated, check whether they actually use it. The site I copied did not.
- Reverse animations are optional. When the origin has moved or been clipped, a simple exit transition reads far better than a forced morph back.