IntersectionObserver adalah API yang membuat browser memberi tahu kamu saat sebuah elemen masuk atau keluar dari suatu area: layar, atau kotak yang bisa di-scroll. Bayangin satpam di gerbang kompleks yang mencatat tiap tamu yang lewat. Kamu gak perlu menengok jendela setiap detik; satpam yang menelepon saat ada yang masuk atau keluar.
🧪 Tebakan cepat: Kamu memanggil
observer.observe()untuk tiga kartu kamar yang semuanya masih jauh di bawah, belum terlihat sama sekali. Sebelum kamu scroll sedikit pun, callback-nya dipanggil berapa kali? Jawabannya ada di bagian "Callback pertama datang langsung".
Membuat observer
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) { // kartu ini sudah masuk area
entry.target.classList.add('animate-from-bottom');
observer.unobserve(entry.target); // cukup sekali, berhenti mengamati
}
}
}, { root: null, threshold: 0.1, rootMargin: '0px 0px -50px 0px' });
document.querySelectorAll('.room').forEach((el) => observer.observe(el));
.room {
opacity: 0;
transform: translateY(16px);
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
.room.animate-from-bottom { opacity: 1; transform: none; }
new IntersectionObserver(callback, options)membuat satpamnya;observe(el)mendaftarkan elemen yang diamati.- Callback menerima
entries, satu entry per elemen yang keadaannya berubah. Isinya:target(elemennya),isIntersecting(sudah masuk area atau belum),intersectionRatio(berapa bagian yang terlihat, 0 sampai 1),boundingClientRect, dantime. - Animasinya sendiri tetap CSS transition. JavaScript cuma menambah kelas, sekali.
Callback pertama datang langsung
Kami mengamati tiga kartu Bu Sri (tinggi 120 px) di scroller setinggi 300 px. Begitu observe() dipanggil, sebelum ada scroll:
| Kartu | isIntersecting |
intersectionRatio |
Jarak dari atas area |
|---|---|---|---|
| Kamar Teras | false |
0 | 410 px |
| Kamar Loteng | false |
0 | 540 px |
| Kamar Kebun | false |
0 | 670 px |
Itu jawaban tebakannya: sekali, dengan tiga entry sekaligus, semuanya isIntersecting: false. Sama di Chromium 147, WebKit 26.4, dan Firefox 146. Browser selalu melaporkan keadaan awal tiap elemen. Karena itu if (entry.isIntersecting) wajib; tanpa itu, ketiga kartu langsung muncul sebelum terlihat.
Tiga option: root, threshold, rootMargin
root: area acuannya.null(default) berarti layar (viewport); bisa juga elemen scroller, seperti daftar kamar denganoverflow: auto.threshold: bagian elemen yang harus terlihat sebelum callback dipanggil, dari 0 sampai 1.threshold: 0.5berarti callback dipanggil saat 50% elemen terlihat. Bisa array,[0, 0.5, 1], untuk dipanggil di tiap batas.rootMargin: memperbesar atau memperkecil area root, ditulis seperti margin CSS (atas kanan bawah kiri), wajib bersatuanpxatau%. Negatif = lebih lambat, positif = lebih awal.
Hasil ukur dengan threshold: 0.5, scroll ke bawah lalu lewat:
| Kartu | Saat callback "masuk" | Saat callback "keluar" |
|---|---|---|
| Kamar Teras | isIntersecting: true, ratio 0,54 |
false, ratio 0,46 |
| Kamar Loteng | true, ratio 0,50 |
false, ratio 0,29 |
| Kamar Kebun | true, ratio 0,67 |
false, ratio 0,33 |
Perhatikan baris "keluar": dengan threshold 0,5, kartu yang masih terlihat 46% sudah dilaporkan isIntersecting: false. Di tiga browser yang kami tes, isIntersecting mengikuti threshold-nya.
rootMargin: '0px 0px -50px 0px' memotong 50 px dari bawah area. Kartu Teras baru dilaporkan masuk saat tepi atasnya 236 px dari atas scroller 300 px, bukan begitu menyentuh 300 px. Untuk lazy loading gambar, kebalikannya: '200px 0px' memanggil callback 200 px sebelum gambar terlihat.
Di latihan, kamu merakit object option itu: createObserverConfig(0.1, "0px 0px -50px 0px") tercetak {"root":null,"threshold":0.1,"rootMargin":"0px 0px -50px 0px"}.
Muncul sekali, berurutan
unobserve(entry.target) setelah kelas ditambahkan membuat tiap kartu muncul sekali. Kami scroll turun melewati ketiga kartu: dengan unobserve, cuma 3 callback (satu per kartu); tanpa itu, 6 (tiap kartu masuk, lalu keluar lagi di atas).
Kelasnya bisa dipilih dari arah geraknya. Namanya menyebut asal kartu, bukan arah geraknya:
| Arah gerak | Kelas | Kartunya datang dari |
|---|---|---|
"up" |
animate-from-bottom |
bawah |
"down" |
animate-from-top |
atas |
"left" |
animate-from-right |
kanan |
"right" |
animate-from-left |
kiri |
| lainnya | animate-fade |
gak bergeser, cuma memudar |
Tabel gerak kita menulis kartu kamar muncul berselang 60 ms (stagger). Kartu ke-i mendapat jeda i × 60 ms: createStaggerDelays(3, 60) = [0, 60, 120], dipasang sebagai el.style.transitionDelay = '60ms'. Kartu pertama gak menunggu.
Kenapa bukan listener scroll?
Kami scroll scroller 300 px tadi sejauh 1.000 px dalam 40 langkah:
| Dipanggil | Di mana | |
|---|---|---|
addEventListener('scroll', ...) |
40 kali | main thread, tiap event |
| IntersectionObserver, threshold 0,5 | 6 kali (3 dengan unobserve) |
cuma saat batasnya dilewati |
Angkanya sama di tiga browser. Scroll sungguhan mengirim satu event per frame, sampai 120 kali per detik di layar 120 Hz, dan listener biasanya memanggil getBoundingClientRect() di tiap event, yang bisa memaksa browser menghitung layout. IntersectionObserver menghitung perpotongannya di dalam browser dan cuma menelepon saat ada yang berubah.
IntersectionObserver atau view()?
| IntersectionObserver | animation-timeline: view() |
|
|---|---|---|
| Muncul | sekali, lalu tetap | maju-mundur mengikuti scroll |
| Lamanya | waktu (300 ms) | jarak scroll |
| JavaScript | callback kecil, sekali per kartu | gak ada |
| Dukungan | Chrome 51, Firefox 55, Safari 12.1 | Chrome 115, Safari 26, Firefox rilis belum |
| Kegunaan lain | lazy loading, infinite scroll | cuma animasi |
Kurangi gerakan
Observer-nya gak berubah; CSS yang menentukan. Buang geraknya, sisakan fade 150 ms, dan buang stagger-nya juga:
@media (prefers-reduced-motion: reduce) {
.room { transform: none; transition: opacity 150ms linear; }
}
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
el.style.transitionDelay = reduce ? '0ms' : delays[i] + 'ms';
🤔 rootMargin di dalam iframe
Kalau halamanmu berjalan di iframe beda origin (seperti Playground) dan root-nya null, rootMargin diabaikan. Kami ukur di tiga browser: kartu di 250 px dalam iframe 300 px tetap dilaporkan masuk walau rootMargin: '0px 0px -100px 0px'. Dengan root: document atau elemen scroller sebagai root, margin-nya berlaku.
⚠️ Jebakan yang sering ketemu
- Gak mengecek
isIntersecting. Callback pertama datang langsung untuk semua elemen, jadi semua kartu muncul sebelum terlihat. threshold: 1untuk elemen yang lebih tinggi dari area. Elemen 400 px gak pernah 100% terlihat di area 300 px; kami ukur: setelah callback pertama, gak ada lagi.- Lupa
unobserve. Callback terus datang setiap kartu masuk dan keluar. rootMargintanpa satuan.rootMargin: '-50'melemparSyntaxError: ... rootMargin must be specified in pixels or percent.- Menyembunyikan konten tanpa JavaScript. Kalau script gagal,
.room { opacity: 0 }membuat kartunya gak pernah terlihat. Tambahkan kelas penyembunyinya dari JavaScript juga.
TL;DR: new IntersectionObserver(callback, { root, threshold, rootMargin }) membuat browser menelepon saat elemen yang di-observe() melewati batas area: root = area (null = layar, atau elemen scroller), threshold = bagian yang harus terlihat (0,5 = saat 50% terlihat), rootMargin = area diperbesar/diperkecil ('0px 0px -50px 0px', wajib bersatuan). Callback pertama datang langsung untuk semua elemen dengan isIntersecting: false, jadi selalu cek entry.isIntersecting, tambahkan kelas animasinya, lalu unobserve(entry.target) supaya muncul sekali. Kelas menyebut asal kartu ("up" jadi animate-from-bottom), stagger kartu ke-i = i × 60 ms. Dibanding listener scroll: 6 callback lawan 40 event untuk scroll yang sama. Dibanding view(): muncul sekali dan didukung semua browser, tapi butuh sedikit JavaScript. Dengan "Kurangi gerakan", CSS membuang gerak dan stagger, menyisakan fade 150 ms.