Intersection Observer — Web Animation

Intersection Observer untuk Animasi IntersectionObserver API memungkinkan kamu mendeteksi kapan elemen masuk atau keluar dari viewport (atau container…

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; }

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

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

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.

Yang akan kamu pelajari