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.
markbukannull, selector-nya kena.- Timeline-nya jalan,
onCompleteterpanggil. - Tidak ada
overwriteyang membunuh tween-nya. - Di DevTools, atribut
styleelemennya beneran berubah tiap frame. Angkaopacity-nya turun dari 1 ke 0 persis seperti yang saya minta.
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:
- Deklarasi author normal, termasuk atribut
style=di elemen - Deklarasi animasi CSS
- Deklarasi author
!important - 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
- Inline style menang di urusan specificity, tapi kalah dari deklarasi animasi CSS karena keduanya bertanding di origin cascade yang berbeda.
- Animasi
infiniteadalah kasus terburuknya. Tidak pernah ada jeda di mana dia menyerahkan kembali kendali propertinya. animation-play-state: pausedmembekukan waktu, bukan melepas kepemilikan. Untuk melepas, setanimation: noneatau buang class-nya.- Sebelum menyerahkan sebuah properti ke tween, ambil dulu nilai komputasinya supaya sambungan dari animasi ke tween tidak lompat.
- Kalau inline style berubah di DevTools tapi layar tidak ikut berubah, berhenti mencurigai library animasimu. Yang salah ada di lapisan di atasnya.