D
P
0
← Semua artikel Read in English

JavaScript, DOM & Animasi Browser

Teks Muncul, Lalu Ke-hide, Lalu Animasi Lagi? Safety Net `force-reveal` yang Menyelamatkan Terlalu Banyak

· · 6 menit baca
Teks Muncul, Lalu Ke-hide, Lalu Animasi Lagi? Safety Net `force-reveal` yang Menyelamatkan Terlalu Banyak

Bug paling menyebalkan biasanya bukan yang lahir dari kode ceroboh, tapi yang lahir dari kode pengaman. Saya menabrak satu contoh sempurna di sebuah landing page pre-launch: fitur yang saya pasang khusus supaya animasi tidak pernah gagal justru jadi sumber bug yang dilaporkan klien.

Laporannya masuk dengan kalimat yang sangat deskriptif dan sangat tidak teknis: "teks muncul, terus animasi, terus teks lagi".

Gejalanya: cuma kelihatan kalau scroll-nya santai

Yang bikin saya lama menemukannya, bug ini tidak muncul di cara saya menguji. Saya biasa scroll cepat, lompat ke section, refresh, ulang. Semua mulus.

Klien tidak begitu. Dia membaca. Dia buka halamannya, diam sebentar di hero, baca headline, lalu turun pelan-pelan sambil menyerap isinya. Dan di pola itu bug-nya konsisten:

  1. Dia scroll pelan ke bawah.
  2. Satu blok teks sudah terlihat penuh sebelum benar-benar masuk viewport.
  3. Begitu blok itu masuk viewport, teksnya menghilang.
  4. Lalu animasi masuknya jalan dari nol, dan teksnya muncul lagi.

Jadi konten yang sudah dia baca setengah tiba-tiba dicabut dari depan matanya lalu dipasang ulang dengan gaya. Dari sisi teknis tidak ada yang error. Console bersih. Dari sisi orang yang sedang membaca, itu terasa seperti halamannya rusak.

Kenapa ada safety net-nya sejak awal

Konteksnya penting, karena net ini bukan kode iseng. Aturan animasi di proyek itu memang saya buat paranoid, hasil dari luka lama di proyek lain: elemen reveal tidak boleh pernah tersembunyi total, observer dipasang dengan threshold rendah (0.05), setiap elemen ditandai eksplisit satu per satu, dan ada satu jaring pengaman terakhir yang memaksa semua elemen tampil sekitar empat detik setelah load.

Satu hal yang harus saya akui di sini. Di proyek lama itu, net-nya sempat mengecek dulu apakah elemennya benar-benar ada di layar sebelum dipaksa tampil. Yang ikut pindah ke proyek ini cuma versi ringkas aturannya, "net force-reveal sekitar empat detik", dan cek itu tertinggal di jalan. Aturan yang dipadatkan jadi satu baris memang gampang kehilangan bagian yang justru bikin dia aman.

Alasannya masuk akal. IntersectionObserver bisa gagal memicu karena banyak hal: scroll super cepat yang melewati elemen tanpa frame intersection yang tertangkap, layout yang bergeser setelah font atau gambar masuk, quirk browser. Kalau itu terjadi dan elemennya tetap dalam pre-state, konten hilang permanen tanpa jejak.

Jadi saya pasang ini:

window.addEventListener("load", () => {
  setTimeout(() => {
    document.querySelectorAll(".reveal:not(.is-visible)").forEach((el) => {
      el.classList.add("is-visible");
    });
  }, 4000);
});

Empat detik lewat, semua yang belum tampil dipaksa tampil. Aman, pikir saya.

Akar masalahnya: net-nya menyelamatkan yang tidak minta diselamatkan

Perhatikan selektornya. document.querySelectorAll(".reveal:not(.is-visible)") mengambil semua elemen reveal di halaman. Semuanya. Termasuk yang ada di footer, tiga layar di bawah tempat pengunjung sedang berdiri.

Empat detik setelah load, net itu membuka semuanya. Elemen di bawah fold, yang observer-nya memang belum seharusnya jalan karena elemennya belum kelihatan, ikut dibuka. Tidak ada yang salah secara visual saat itu, karena tidak ada yang melihat area itu.

Masalahnya baru meledak nanti. Saat pengunjung akhirnya scroll ke sana, observer bekerja persis seperti seharusnya: elemennya masuk viewport, callback jalan, animasi masuk dieksekusi. Dan animasi masuknya digerakkan anime.js, yang menetapkan pre-state-nya sendiri di detik animasi diputar, bukan di detik halaman dimuat.

Urutannya jadi begini:

Persis kalimat klien: teks, animasi, teks. Net dan observer sebenarnya sama-sama bekerja sempurna. Yang tidak pernah terjadi adalah keduanya saling memberi tahu.

Dan kenapa cuma kelihatan saat scroll santai? Karena kalau scroll-nya cepat, pengunjung sudah lewat jauh sebelum timer empat detik itu relevan, atau elemennya masuk viewport sebelum net sempat menyentuhnya. Bug ini butuh orang yang membaca untuk muncul, dan itu justru pengunjung yang paling penting.

Perbaikannya: net yang sadar viewport, dan pakai strikes

Saya kembali ke pertanyaan dasar: net ini sebenarnya melindungi dari apa?

Jawabannya cuma satu kasus, dan kasusnya sangat sempit: elemen yang sedang ada di layar sekarang tapi observer-nya tidak kunjung memicu. Itu saja. Elemen di bawah fold yang belum tampil bukan kegagalan, itu perilaku normal. Menyelamatkannya bukan cuma tidak perlu, tapi justru merusak.

Jadi net-nya saya ubah dari sekali sapu jadi patroli berkala yang cuma melirik apa yang benar-benar ada di layar. Dan supaya tidak salah tuduh pada elemen yang observer-nya baru saja mau jalan, saya pakai sistem strikes: sebuah elemen harus tertangkap dalam kondisi "di layar tapi masih belum tampil" sebanyak dua kali berturut-turut, dengan jeda 1,5 detik, sebelum dipaksa tampil.

const STRIKE_INTERVAL = 1500;
const STRIKES_NEEDED = 2;
const strikes = new WeakMap();
 
function isOnScreen(el) {
  const rect = el.getBoundingClientRect();
  return rect.top < window.innerHeight && rect.bottom > 0;
}
 
function patrol() {
  const pending = document.querySelectorAll(".reveal:not(.is-visible)");
 
  pending.forEach((el) => {
    if (!isOnScreen(el)) {
      strikes.delete(el); // di bawah fold, bukan urusan net
      return;
    }
 
    const count = (strikes.get(el) || 0) + 1;
    strikes.set(el, count);
 
    if (count >= STRIKES_NEEDED) settle(el);
  });
}
 
window.addEventListener("load", () => {
  setInterval(patrol, STRIKE_INTERVAL);
});

Bagian kedua perbaikannya sama pentingnya. Net tidak boleh cuma mengubah tampilan, dia harus menutup kasusnya supaya observer tidak mengulang apa pun nanti. Karena itu keduanya lewat satu pintu yang sama:

function settle(el) {
  if (el.classList.contains("is-visible")) return; // sekali saja per elemen
 
  el.classList.add("is-visible");
  io.unobserve(el); // observer tidak akan pernah memicu ulang
  playEntrance(el); // pemanggilan anime.js, dari pre-state
}
 
const io = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) settle(entry.target);
    });
  },
  { threshold: 0.05 }
);

playEntrance di situ adalah pemanggilan anime.js yang dulu duduk langsung di dalam callback observer. Sekarang dia cuma bisa dicapai lewat settle, dan settle berhenti di baris pertama kalau elemennya sudah pernah ditampilkan. Jadi animasi masuk tetap jalan, tapi maksimal sekali seumur hidup elemen itu.

Sekarang siapa pun yang menang duluan, net atau observer, elemennya berhenti jadi kandidat animasi. Tidak ada lagi jalur kedua yang bisa memasang pre-state di atas konten yang sudah tampil.

Setelah itu, scroll pelan di landing yang sudah live jadi tenang. Teks muncul sekali, dari pre-state, seperti niat awalnya.

Yang saya bawa pulang