IntersectionObserver reports elements entering or leaving the viewport or a scrolling root, for lazy loading, infinite scroll and ad impressions. threshold sets the visible fractions that trigger callbacks; rootMargin grows the root box so work starts early.
<div id="feed" style="height: 100px; width: 300px; overflow-y: auto; border: 1px solid #999"></div>
<pre id="out" style="font: 14px/1.5 monospace; margin: 8px 0 0"></pre>
<script>
const row = (_, i) => `<p style="height: 25px; margin: 0">${i + 1}</p>`;
feed.innerHTML = Array.from({ length: 12 }, row).join('');
feed.scrollTop = 110; // rows 5-9 are (partly) visible
const ids = (entries, inView) => entries.filter((e) => e.isIntersecting === inView)
.map((e) => e.target.textContent).join(' ');
const observer = new IntersectionObserver((entries) => {
out.textContent += `in [${ids(entries, true)}] out [${ids(entries, false)}]\n`;
}, { root: feed, rootMargin: '0px 0px 30px 0px' }); // report rows 30px before they appear
feed.querySelectorAll('p').forEach((p) => observer.observe(p));
</script>
The first callback reports each target's initial state; row 10, still out of sight, is in because of rootMargin. Later callbacks list only rows whose state changed. Images and iframes need only loading="lazy".