Kadang laporan bug dari klien lebih tepat daripada istilah teknis yang saya punya untuk menggantikannya. Kali ini kalimatnya begini, dan saya kutip persis: "teks dulu animasi baru teks lagi".
Enam kata, dan di dalamnya sudah ada seluruh urutan kejadian yang perlu saya perbaiki. Ini bukan temuan saya sendiri di layar sendiri, ini teguran yang datang dari sisi klien tentang animasi intro hero yang duduk di belakang preloader.
Tiga tahap yang dilaporkan, semuanya di satu kedipan
Kalau kalimat tadi diurai, yang dilaporkan sebenarnya tiga tahap berturut-turut. Teks hero tampil penuh selama satu frame. Lalu teks itu disembunyikan. Lalu baru dia dianimasikan masuk seperti seharusnya.
Satu frame terdengar terlalu singkat untuk jadi keluhan. Di 60 fps, satu frame itu 1000 dibagi 60, sekitar 16 milidetik. Tapi kedipan sependek itu tetap kelihatan justru karena kontrasnya ekstrem: dari konten penuh, ke kosong, lalu isi lagi. Mata tidak perlu membaca teksnya untuk sadar ada sesuatu yang muncul dan langsung dicabut.
set() menunggu preloader, hero-nya tidak ikut menunggu
Bentuknya kira-kira begini, dengan nama dan nilai yang saya sederhanakan, dan ketiga tahap itu sudah bisa dibaca dari situ. Semua initial state gsap.set dipanggil di dalam callback selesai-preloader, satu paket dengan timeline animasinya.
document.addEventListener("DOMContentLoaded", () => {
runPreloader({
onComplete: () => {
// initial state ikut menumpang di sini, dan ini masalahnya
gsap.set(".hero__title span", { yPercent: 110, opacity: 0 });
gsap.set(".hero__subtitle", { opacity: 0 });
gsap
.timeline()
.to(".hero__title span", { yPercent: 0, opacity: 1, stagger: 0.06 })
.to(".hero__subtitle", { opacity: 1 }, "-=0.3");
},
});
});Masalahnya, hero itu sudah ter-render di belakang preloader sejak halaman dimuat. Preloader hanya menutupinya, tidak menundanya. Jadi selama preloader masih di depan, hero sudah ada di sana dalam kondisi apa adanya: teks penuh, tanpa yPercent, tanpa opacity: 0, karena tidak ada satu pun set() yang sudah dijalankan.
Begitu preloader terangkat, frame pertama yang sampai ke mata pengunjung adalah konten penuh yang belum tersentuh itu. Baru setelah frame tersebut set() jalan dan menyembunyikannya, lalu timeline menganimasikannya masuk. Urutannya persis seperti yang dilaporkan.
Yang bikin kasus ini enak dijelaskan setelah ketahuan: semua kodenya memang jalan, dan urutan lengkapnya sampai ke mata pengunjung. Yang salah cuma satu, ada bagian yang jalan satu frame terlalu telat.
Perbaikannya: pisahkan yang menyiapkan dari yang memainkan
Perbaikannya bukan menggeser timing atau menambah delay, tapi memecah intro jadi dua fungsi dengan waktu panggil yang berbeda.
Fungsi pertama isinya SEMUA initial state gsap.set, dan dipanggil langsung di DOMContentLoaded, selagi preloader masih menutup halaman. Fungsi kedua isinya hanya timeline .to(), dan dipanggil saat preloader selesai.
function prepIntro() {
// semua initial state, tanpa kecuali
gsap.set(".hero__title span", { yPercent: 110, opacity: 0 });
gsap.set(".hero__subtitle", { opacity: 0 });
}
function initIntro() {
// hanya timeline .to(), tidak ada set() di sini
gsap
.timeline()
.to(".hero__title span", { yPercent: 0, opacity: 1, stagger: 0.06 })
.to(".hero__subtitle", { opacity: 1 }, "-=0.3");
}
document.addEventListener("DOMContentLoaded", () => {
prepIntro();
runPreloader({ onComplete: initIntro });
});Sekarang penyembunyian terjadi di balik preloader, di saat tidak ada mata yang bisa melihatnya. Frame pertama setelah preloader terangkat menampilkan hero yang sudah dalam state awal animasi, bukan konten penuh. Kedipannya hilang bukan karena dipercepat, tapi karena dipindahkan ke belakang tirai.
Satu jaring pengaman kalau timeline preloader mati
Konsekuensi dari pemecahan ini: hero sekarang disembunyikan lebih awal dan lebih pasti, jadi kalau initIntro() tidak pernah terpanggil, halaman berhenti dalam keadaan kosong. Karena itu ada failsafe timeout yang tetap memanggil intro kalau timeline preloader mati.
let introStarted = false;
function playIntroOnce() {
if (introStarted) return;
introStarted = true;
initIntro();
}
// angka contoh, sesuaikan dengan durasi wajar preloader di situsmu
const FAILSAFE_MS = 4000;
document.addEventListener("DOMContentLoaded", () => {
prepIntro();
runPreloader({ onComplete: playIntroOnce });
setTimeout(playIntroOnce, FAILSAFE_MS);
});Penjaga introStarted ada supaya intro tidak jalan dua kali kalau preloader ternyata selesai normal sebelum timeout-nya lewat.
Yang saya bawa pulang
- Aturannya satu kalimat: initial state animasi intro harus di-set SEBELUM preloader terangkat, jangan sampai teks tampil lalu hilang lalu animasi masuk.
- Preloader menutupi hero, bukan menunda render-nya. Hero sudah ada di belakang sana sejak halaman dimuat, dalam kondisi apa adanya.
- Memisahkan
prepdariplaybukan soal rapi-rapi kode. Dua fungsi itu memang harus jalan di dua waktu yang berbeda. - Failsafe timeout jadi wajib begitu kamu menyembunyikan konten lebih awal, karena kegagalan preloader sekarang berarti halaman kosong.
- Pola dua fungsi ini saya jadikan standar untuk setiap intro yang digerbangi preloader di proyek saya. Kasusnya sendiri sudah muncul di dua proyek.
Stack-nya GSAP dan JavaScript di dalam tema WordPress.