D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

Shared Element Morph Invisible on Open? The Clone Inherits the Overlay's `opacity` Fade

· · 6 min read
Shared Element Morph Invisible on Open? The Clone Inherits the Overlay's `opacity` Fade

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:

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