D
P
0
← Semua artikel Read in English

JavaScript, DOM & Animasi Browser

Tween GSAP Diabaikan dan Muncul Elemen "Ghost"? `animation: infinite` Mengalahkan Inline Style

· · 5 menit baca
Tween GSAP Diabaikan dan Muncul Elemen "Ghost"? `animation: infinite` Mengalahkan Inline Style

Bug paling menjengkelkan bukan yang meledak, tapi yang berpura-pura berhasil. GSAP bilang tween-nya jalan. Console bersih. Timeline selesai tepat waktu. Tapi di layar, elemennya tidak bergerak sedikit pun.

Saya kena versi paling licik dari kelas bug ini waktu merapikan preloader di sebuah situs company profile. Ada crossfade kecil di dalam preloader: mark (logo bentuk simbol) memudar keluar sementara lockup (logo lengkap dengan teks) memudar masuk. Sederhana. Dua tween, satu timeline.

Hasilnya: mark-nya tidak pernah benar-benar pergi. Dia nyangkut di layar sebagai elemen ghost, menumpuk di atas lockup yang sudah muncul, lalu baru hilang mendadak saat preloader-nya sendiri disembunyikan di akhir.

Gejalanya: tween "berhasil" tapi tidak berefek

Kode crossfade-nya polos:

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

Lockup-nya masuk dengan benar. Mark-nya tidak. Dan yang bikin saya buang waktu lama: semua yang saya periksa jawabannya benar.

Itu detail terakhir yang bikin saya berhenti. Inline style-nya ada. Nilainya benar. Tapi elemennya tetap terlihat penuh, tidak transparan sama sekali. Jadi masalahnya bukan GSAP gagal menulis. Masalahnya ada sesuatu yang menang di atas tulisan itu.

Akar masalahnya: animasi CSS punya lapisan sendiri di cascade

Sehari sebelumnya, waktu menyusun preloader-nya, saya menambahkan efek "napas" halus ke mark supaya tidak terasa mati saat menunggu aset. Satu baris CSS yang sudah tidak saya ingat lagi waktu menulis crossfade-nya:

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

Perhatikan properti yang dipakai keyframes itu: opacity dan transform. Persis dua properti yang di-tween GSAP.

Di sinilah bagian yang sering disalahpahami orang, saya termasuk. Kita terbiasa berpikir inline style adalah pemenang mutlak, karena dalam urusan specificity dia memang mengalahkan class, id, apa pun. Tapi animasi CSS tidak bertanding di lapangan specificity. Dia punya origin sendiri di cascade, dan origin itu duduk di atas semua deklarasi author normal, termasuk inline style.

Urutan cascade yang relevan, dari yang kalah ke yang menang:

  1. Deklarasi author normal, termasuk atribut style= di elemen
  2. Deklarasi animasi CSS
  3. Deklarasi author !important
  4. Deklarasi transisi CSS

Jadi selama logoBreath masih berputar, setiap frame browser menimpa ulang opacity dan transform elemen itu dengan nilai dari keyframes. GSAP menulis opacity: 0.4 ke inline style, browser membacanya, lalu berkata "maaf, ada animasi yang lagi jalan di properti ini" dan memakai nilai keyframes. Frame berikutnya persis sama. Tujuh ratus milidetik penuh tarik menarik yang selalu dimenangkan CSS.

Dan karena logoBreath itu infinite, tidak pernah ada momen animasi itu berhenti dan menyerahkan kendali. Mark-nya tetap terlihat, terus bernapas, sementara lockup muncul di belakangnya. Itulah ghost-nya.

Ini beda dari kasus tween yang ditimpa requestAnimationFrame loop, yang bentrokannya terjadi di level JavaScript dan bisa kelihatan kalau kamu log nilainya. Di sini JavaScript-nya menang, nilainya masuk ke DOM dengan benar, dan yang membuangnya adalah lapisan cascade yang tidak muncul di log mana pun.

Perbaikannya: matikan animasinya dulu, baru tween

Aturannya jadi sederhana setelah tahu penyebabnya. Jangan pernah men-tween properti yang sedang dipegang animasi CSS. Lepaskan dulu kepemilikannya, baru serahkan ke GSAP.

function releaseFromCss(el) {
  el.style.animation = "none";
  // paksa reflow supaya browser mencatat perubahan ini
  // sebelum tween di frame yang sama mulai menulis
  void el.offsetWidth;
}
 
releaseFromCss(mark);
 
tl.to(mark, { opacity: 0, scale: 0.8, duration: 0.7, ease: "power2.in" });

Kalau animasinya ditempel lewat class terpisah, misalnya .is-breathing, melepas class-nya sama efektifnya dan lebih bersih:

mark.classList.remove("is-breathing");

Yang penting bukan caranya, tapi urutannya: animasi mati dulu, tween belakangan.

Kenapa animation-play-state: paused tidak menyelesaikan masalah

Refleks pertama saya adalah mem-pause animasinya, bukan mematikannya. Itu tidak bekerja, dan alasannya masuk akal begitu dipikir ulang.

Pause hanya membekukan waktu animasi. Animasinya masih aktif, masih menempati origin cascade-nya, dan masih menerapkan nilai keyframes pada posisi waktu terakhirnya. Ghost-nya cuma berubah dari bernapas jadi diam. Untuk benar-benar mengembalikan kendali ke inline style, animasinya harus dihapus, bukan dijeda.

Menjahit sambungannya supaya tidak lompat

Karena logoBreath bergerak di antara opacity: 1 dan 0.55, mematikannya di tengah siklus bisa bikin elemennya lompat balik ke nilai dasar sebelum tween mulai. Di durasi 0.7 detik lompatan itu kelihatan.

Solusinya: catat dulu nilai aktual saat itu, baru pakai sebagai titik awal tween.

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

Sekarang tween-nya mulai persis dari kondisi visual terakhir yang dilihat pengunjung, dan sambungannya tidak terasa.

Cek cepat yang sekarang selalu saya jalankan

Setiap kali ada tween yang "jalan tapi tidak berefek", pertanyaan pertama saya bukan lagi soal GSAP. Saya tanya elemennya langsung, apakah dia sedang dipegang animasi CSS:

const running = getComputedStyle(el).animationName;
if (running !== "none") {
  console.warn("Elemen ini punya animasi CSS aktif:", running);
}

Satu baris, dan langsung memisahkan dua kemungkinan yang tampak identik dari luar: GSAP tidak menulis, atau GSAP menulis tapi tulisannya dibuang.

Epilog: crossfade-nya akhirnya saya hapus

Bagian yang jujur dari cerita ini: setelah crossfade-nya benar dan mulus, saya melihatnya beberapa kali di perangkat asli dan menyimpulkan preloader-nya lebih enak tanpa transisi internal itu. Mark langsung jadi lockup terasa lebih tegas daripada dua logo yang saling menyeberang di depan mata pengunjung selama lebih dari satu detik.

Jadi versi finalnya tidak memakai crossfade sama sekali. Tapi pengetahuannya tetap terpakai, karena pola yang sama muncul lagi tiap kali ada elemen dengan animasi idle yang harus diambil alih JavaScript, dan itu sering: tombol yang berdenyut, panah scroll yang naik turun, badge yang berkedip.

Yang saya bawa pulang