Sebuah situs klien punya satu section "mission" yang tinggi: 350vh, dengan panel yang menempel di tengah layar (sticky) selama kamu scroll melewatinya. Di dalam panel itu ada tiga elemen yang harusnya muncul dengan animasi begitu section masuk layar: eyebrow kecil di atas, heading besar, dan satu paragraf isi. Di lokal sekilas kelihatan jalan, tapi pas di-review klien lapor: teks di panel mission ga muncul sama sekali.
Yang tampil cuma latarnya. Eyebrow-nya ke-clip, ga kelihatan. Heading-nya diam di posisi translateY(110%), masih di bawah garis dan ke-mask. Paragraf isinya blur di opacity: 0. Ketiganya nunggu satu hal yang sama: class .is-revealed yang harusnya ditambahkan ke section begitu dia kelihatan. Class itu ga pernah datang.
Gejalanya
Buka DevTools, section-nya ada, elemennya ada, CSS-nya benar. Kalau saya tambahkan .is-revealed manual lewat inspector, ketiga elemen langsung reveal mulus. Jadi CSS-nya sehat. Masalahnya di JavaScript yang harusnya menambahkan class itu.
Kodenya pakai IntersectionObserver. Saya kira gampang. Saya taruh console.log di dalam callback-nya:
const obs = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
console.log('ratio', entry.intersectionRatio, entry.isIntersecting);
if (entry.isIntersecting) root.classList.add('is-revealed');
});
}, { threshold: 0.3 });
obs.observe(section);Saya scroll pelan dari atas sampai bawah melewati section. Callback-nya jalan, log-nya keluar, tapi isIntersecting ga pernah true. Ratio-nya naik, mentok di sekitar 0,28, lalu turun lagi. Ga pernah menyentuh 0,3. Callback dipanggil, tapi cabang if-nya ga pernah kena.
Dead end yang saya coba dulu
Refleks pertama saya salah semua. Saya kira observer-nya kena timing: mungkin section-nya belum ter-layout waktu observer dibuat. Saya bungkus dengan requestAnimationFrame, ga ngefek. Saya curiga smooth scroll (Lenis) bikin observer ga update, saya matikan sementara, tetap ga fire. Saya pikir mungkin root observer-nya salah, saya set eksplisit ke null alias viewport, tetap sama.
Semua tebakan itu meleset karena saya ga baca angka yang sudah ada di depan mata: ratio mentok di 0,28.
Akar masalah: threshold-nya mustahil terpenuhi
Ini bukan bug timing, bukan bug scroll library. Ini matematika.
intersectionRatio itu rasio antara luas elemen yang kelihatan di viewport dibagi luas total elemennya. Section saya tingginya 350vh. Viewport-nya 100vh. Berapa pun kamu scroll, bagian yang bisa kelihatan dari elemen setinggi 350vh di jendela setinggi 100vh maksimal cuma 100 dari 350, alias 0,286.
Threshold 0,3 minta 30% dari elemen kelihatan sekaligus. Untuk elemen yang 3,5 kali lebih tinggi dari viewport, itu mustahil. Ratio-nya secara fisik ga akan pernah sampai 0,3. Observer-nya sehat, callback-nya jalan, tapi syarat yang saya kasih ga akan pernah benar. isIntersecting di API ini dihitung terhadap threshold yang aktif, jadi dia ikut ga pernah true di titik yang saya harapkan.
Menurunkan threshold ke 0,2 pun bukan jawaban. 0,2 memang bakal fire, tapi dia nyala waktu section baru masuk seperlima layar, bukan waktu panel sticky-nya benar-benar duduk di tengah dan teksnya siap dibaca. Reveal-nya jadi kepagian dan ga sinkron sama momen yang saya mau.
Perbaikannya: observe elemen yang ukurannya masuk akal
Kuncinya: jangan observe container 350vh-nya. Observe panel sticky di dalamnya yang tingginya 100vh (.section__sticky-inner). Elemen itu ukurannya sepadan dengan viewport, jadi rasio-nya bisa naik sampai penuh dan threshold jadi bermakna lagi.
Karena saya cuma butuh tahu "panel ini sudah masuk layar atau belum", saya pakai threshold: 0 plus rootMargin biar reveal-nya nyala sedikit setelah panel benar-benar masuk, bukan pas ujungnya baru nyenggol viewport:
const target = root.querySelector('.section__sticky-inner') || root;
const obs = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
root.classList.add('is-revealed');
obs.disconnect();
}
});
}, { threshold: 0, rootMargin: '0px 0px -5% 0px' });
obs.observe(target);Satu hal lagi yang saya tambahkan setelah kena gigit sekali: safety timer. IntersectionObserver bisa diam di beberapa kondisi tepi, misalnya halaman dipulihkan dari bfcache, smooth-scroll library yang lagi ambil alih scroll, atau user yang pakai reduced-motion. Jadi saya kasih fallback 4 detik yang memaksa reveal kalau elemennya memang lagi di layar tapi observer belum sempat bunyi:
setTimeout(() => {
if (!root.classList.contains('is-revealed')) {
const r = root.getBoundingClientRect();
if (r.top < window.innerHeight && r.bottom > 0) {
root.classList.add('is-revealed');
}
}
}, 4000);Setelah dua perubahan ini, teksnya reveal setiap kali, di scroll normal maupun smooth scroll, dan ga ada lagi laporan "teksnya ga muncul".
Checklist
- Kalau
IntersectionObservercallback jalan tapiisIntersectingga pernahtrue, cek duluintersectionRatiomaksimal yang benar-benar tercapai. - Untuk elemen yang lebih tinggi dari viewport, rasio maksimalnya sama dengan tinggi viewport dibagi tinggi elemen. Elemen 350vh di viewport 100vh mentok di 0,286.
- Jangan pasang
thresholdyang lebih besar dari rasio maksimal itu. Kalau ragu, pakaithreshold: 0dan atur timing lewatrootMargin. - Observe elemen yang ukurannya sepadan dengan viewport, yaitu panel sticky 100vh, bukan container scroll raksasa di luarnya.
- Tambahkan fallback timer buat kondisi tepi seperti bfcache, smooth scroll, dan reduced-motion, biar reveal ga pernah nyangkut permanen.
