D
P
0
← Semua artikel Read in English

JavaScript, DOM & Animasi Browser

`is-open` Nyangkut Sejak Halaman Load? Callback `.add()` di Timeline GSAP Ikut Jalan Saat ScrollTrigger Refresh

· · 4 menit baca
`is-open` Nyangkut Sejak Halaman Load? Callback `.add()` di Timeline GSAP Ikut Jalan Saat ScrollTrigger Refresh

Hero yang saya bangun untuk sebuah tema WordPress premium konsepnya sederhana kalau dijelaskan: dua panel menutup layar, lalu terbelah membuka seperti celah pintu begitu pengunjung mulai scroll. Animasinya scrubbed, jadi lebar celahnya mengikuti posisi scroll, bukan jalan sendiri dengan durasi tetap.

Karena latar di baliknya gelap dan panel penutupnya terang, tinta teks hero harus ikut berganti. Waktu masih tertutup, teksnya gelap supaya kebaca di atas panel. Waktu celahnya sudah terbuka penuh, teksnya putih supaya kebaca di atas latar gelap. Sakelarnya cuma satu class, is-open.

Gejalanya

Halaman selesai load. Belum ada yang menyentuh scroll. Dan teks hero-nya sudah putih.

Panelnya masih rapat, yang kelihatan masih permukaan terang, dan judul putih itu duduk di atasnya dalam kondisi nyaris tak terbaca. Bukan hilang, bukan pudar, cuma kontrasnya habis. Tidak ada error di console, tidak ada warning, tidak ada yang gagal. Animasinya sendiri jalan mulus waktu di-scroll. Yang salah cuma satu hal: state-nya sudah "terbuka" padahal visualnya masih tertutup.

Tersangka pertama yang ternyata bersih

Dugaan paling murah selalu markup. Mungkin is-open memang tertulis di template dan tidak pernah dilepas. Saya cek sumber halamannya, class itu tidak ada di sana. Berarti ada sesuatu yang menempelkannya setelah JavaScript jalan.

Dan di seluruh file hero, cuma ada satu baris yang menyentuh class itu, yaitu callback di dalam timeline:

const tl = gsap.timeline({
  scrollTrigger: {
    trigger: hero,
    start: "top top",
    end: "+=100%",
    scrub: true,
  },
});
 
tl.to(leftPanel, { xPercent: -100, duration: 1 }, 0)
  .to(rightPanel, { xPercent: 100, duration: 1 }, 0)
  .add(() => hero.classList.add("is-open"), 0.85);

Logikanya kelihatan rapi. Timeline-nya sepanjang satu detik, jadi posisi 0.85 itu tepat 85 persen perjalanan. Taruh callback di situ, dan saat playhead lewat titik itu, class-nya nyala. Yang saya lewatkan adalah asumsi diam-diam di baliknya, yaitu bahwa playhead cuma bergerak kalau ada yang benar-benar scroll.

Akar masalahnya: refresh bukan playback

ScrollTrigger tidak hanya menggerakkan timeline waktu kamu scroll. Dia juga melakukan refresh, yaitu menghitung ulang posisi start dan end setiap kali ukuran layout berubah. Refresh terjadi waktu halaman pertama kali siap, terjadi lagi setelah aset dan font selesai masuk, dan terjadi setiap kali viewport diubah ukurannya.

Selama proses pengukuran itu, playhead timeline ikut digeser. Dan callback yang duduk di atas timeline tidak punya cara untuk tahu bedanya. Buat dia, "pengunjung sudah scroll sampai 85 persen" dan "ScrollTrigger sedang mengukur ulang" adalah kejadian yang persis sama: playhead lewat, callback jalan.

Jadi is-open sudah ditempel di detik-detik pertama halaman hidup, jauh sebelum ada yang menyentuh mouse. Dan karena callback-nya cuma menambah class tanpa pernah ada pasangan yang menghapusnya, satu kali salah panggil sudah cukup untuk membuat state-nya nyangkut permanen.

Ini yang akhirnya saya bawa pulang dari kasus ini: callback di dalam timeline itu event, bukan state. Dia menjawab pertanyaan "barusan ada yang terjadi", bukan "sekarang kondisinya seperti apa". Warna tinta hero jelas termasuk kondisi, bukan kejadian, jadi sejak awal saya memakai alat yang salah untuk pekerjaan itu.

Perbaikannya: baca progress, jangan menunggu dipanggil

Solusinya bukan mencari cara menahan callback supaya tidak fire saat refresh. Solusinya membuang callback itu, lalu menghitung state langsung dari progress ScrollTrigger di setiap update:

gsap
  .timeline({
    scrollTrigger: {
      trigger: hero,
      start: "top top",
      end: "+=100%",
      scrub: true,
      onUpdate: (self) => {
        hero.classList.toggle("is-open", self.progress > 0.85);
      },
    },
  })
  .to(leftPanel, { xPercent: -100, duration: 1 }, 0)
  .to(rightPanel, { xPercent: 100, duration: 1 }, 0);

Perhatikan classList.toggle dengan argumen kedua boolean. Class-nya tidak lagi ditambah di satu tempat dan diharapkan hilang sendiri di tempat lain. Setiap update, state-nya ditulis ulang secara eksplisit dari satu angka, yaitu self.progress.

Efek sampingnya persis yang saya butuhkan. Waktu refresh jalan dan progress-nya masih 0, toggle menerima false dan class-nya dilepas. Tidak ada lagi kejadian yang bisa salah dibaca, karena tidak ada lagi yang menunggu kejadian. Yang ada cuma angka yang dibaca ulang terus menerus.

Kenapa 0.85 dan bukan 1

Angkanya bukan hasil menebak. 0.85 adalah titik di mana celahnya sudah terbuka penuh secara visual, jadi di situlah latar gelap benar-benar mendominasi di belakang teks. Menunggu sampai progress 1 berarti tintanya baru berganti setelah pergerakan panel selesai sepenuhnya, dan di mata itu sudah telat: ada rentang di mana latar gelap sudah mendominasi tapi teksnya masih gelap. Karena animasinya scrubbed, panjang rentang itu tergantung seberapa cepat orangnya scroll, jadi tidak ada durasi tetap yang bisa saya sebutkan. Yang pasti, kelihatan.

Verifikasinya gampang karena hasilnya biner. Kondisi tertutup harus tinta gelap, kondisi terbuka harus tinta putih. Setelah perubahan ini, dua-duanya benar sejak load, tanpa perlu scroll dulu untuk "membangunkan" state-nya.

Catatan