D
P
0
← Semua artikel Read in English

JavaScript, DOM & Animasi Browser

Morph Gambar ke Overlay Malah Tidak Kelihatan? Clone di Dalam Overlay Ikut Ketarik `opacity` Induknya

· · 6 menit baca
Morph Gambar ke Overlay Malah Tidak Kelihatan? Clone di Dalam Overlay Ikut Ketarik `opacity` Induknya

Ada satu jenis bug animasi yang paling bikin ragu: animasinya sebenarnya jalan sempurna, cuma kamu tidak bisa melihatnya. Semua angka benar, timeline-nya jalan, onComplete kepanggil tepat waktu, tapi di layar tidak ada apa-apa. Saya kena satu yang seperti itu waktu membangun transisi shared element di homepage sebuah situs company profile kelas premium.

Konteksnya begini. Homepage-nya punya galeri WebGL, tiap tile sebenarnya plane Three.js bertekstur gambar. Klik satu tile, halaman membuka overlay layar penuh berisi cerita panjang, dan gambar yang tadi diklik harusnya terbang mulus dari posisinya di galeri ke posisi hero di dalam overlay. Pola ini saya contek dari satu situs referensi yang aslinya SPA. Situs saya bukan SPA, cuma WordPress biasa tanpa navigasi klien, jadi transisinya harus saya rakit sendiri.

Gejalanya

Tiga hal yang saya catat, dan waktu itu ketiganya kelihatan seperti bug yang berbeda-beda:

Yang bikin saya lama muter-muter, saya menghabiskan waktu memperbaiki angkanya. Saya kira rect tujuannya salah, atau proyeksi dari WebGL ke piksel layar meleset. Jadi saya log semuanya di tengah 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);
  },
});

Angkanya rapi. Bergerak halus dari rect tile ke rect hero, persis seperti yang saya minta. Berarti masalahnya bukan di posisi. Elemennya ada di tempat yang benar, cuma tidak terlihat.

Akar masalahnya: opacity induk mengunci semua anaknya

Waktu itu clone yang sedang morph saya taruh di dalam overlay, karena kelihatannya paling logis. Dia bagian dari transisi overlay, ya sudah, hidupnya di dalam overlay.

Masalahnya, overlay itu sendiri masuk lewat transisi CSS opacity dari 0 ke 1 selama 0,52 detik. Dan opacity di elemen induk bukan sekadar mewarnai induknya. Dia membentuk satu grup komposit. Seluruh subtree dirender dulu jadi satu lapisan, baru lapisan itu yang ditipiskan. Konsekuensinya, tidak ada anak yang bisa lebih pekat dari induknya. Mau saya set opacity: 1 di clone-nya pun percuma, karena angka itu dikalikan dengan opacity induk yang saat itu masih 0,1 atau 0,3.

Sekarang cocokkan angkanya. Morph saya durasinya 0,8 detik, sementara overlay-nya baru sepenuhnya pekat di detik 0,52. Artinya lebih dari separuh perjalanan morph terjadi saat elemennya nyaris tembus pandang. Yang tersisa buat mata cuma potongan terakhir, waktu clone sudah hampir sampai tujuan dan hampir tidak bergerak lagi. Otak membacanya sebagai kedipan, bukan gerakan. Animasinya tidak pernah gagal, dia cuma dijalankan di dalam kotak yang sedang dibuat transparan.

Soal Flip, itu salah alat, dan salahnya ada dua lapis. Pertama, Flip bekerja dengan mengukur elemen DOM di posisi awal lalu di posisi akhir. Sumber saya bukan elemen DOM sama sekali, dia plane di dalam canvas WebGL. Tidak ada node yang bisa diukur, jadi wajar Flip mengeluh sumbernya hilang. Kedua, waktu saya bongkar lagi situs referensi yang saya contek, ternyata mereka sendiri tidak pakai Flip. Mereka cuma gsap.to biasa ke left, top, width, dan height sebuah elemen position: fixed. Saya menambahkan plugin untuk masalah yang memang tidak butuh plugin.

Perbaikannya: clone-nya ditempel ke document.body

Keputusan intinya cuma satu baris, tapi baris itu yang membereskan bug utamanya. Clone tidak boleh hidup di dalam overlay. Dia harus jadi elemen melayang yang ditempel langsung ke document.body, di luar semua grup komposit yang sedang dianimasikan.

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;
}

Rect asalnya saya ambil dari WebGL, dengan memproyeksikan empat sudut plane dari koordinat dunia ke NDC lalu ke piksel klien. Rect tujuannya jauh lebih gampang, cukup getBoundingClientRect() dari pembungkus hero di dalam overlay. Setelah dua rect itu ada, morph-nya jadi tween biasa:

const from = planeRect(mesh);              // proyeksi 4 sudut plane -> piksel layar
const to = heroWrap.getBoundingClientRect();
const clone = spawnFloatingClone(textureSrc, from);
 
heroImg.style.opacity = "0"; // gambar asli tujuan disembunyikan dulu
 
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(),
    });
  },
});

Urutan di onComplete itu penting. Gambar asli dimunculkan dulu, clone-nya baru dibuang setelah itu. Kalau dibalik, ada satu frame kosong di antara keduanya dan mata langsung menangkapnya sebagai kedipan.

Supaya overlay tidak terasa menunggu morph selesai, kaskade kontennya jalan paralel, bukan berurutan:

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]", {
  // dari inset(0% 0% 100% 0%) yang diset di CSS
  clipPath: "inset(0%)",
  duration: 1.2,
  ease: "power3.out",
});

Bagian penutup yang akhirnya saya buang

Reverse morph waktu overlay ditutup tidak pernah saya menangkan. Dua masalahnya saling menguatkan. Rect tujuan reverse itu posisi tile di galeri, dan posisi itu bergerak, jadi clone-nya sering mendarat meleset. Lalu kalau user sudah scroll jauh ke bawah di dalam overlay, gambar hero-nya sudah keluar dari viewport scroller dan kepotong, jadi tidak ada titik asal yang masuk akal untuk memulai morph balik.

Setelah lima iterasi lebih, saya berhenti menambal dan mengganti pendekatannya. Overlay-nya di-scroll dulu ke atas dengan durasi yang menyesuaikan seberapa jauh user sudah turun, baru overlay-nya digeser naik sambil memudar, lalu animasi masuk homepage diputar ulang:

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,
    });
  },
});

Hasilnya lebih cepat, lebih konsisten, dan tidak pernah salah mendarat. Kadang animasi paling bagus adalah animasi yang tidak dipaksa simetris.

Pelajaran