Intersection Observer
Efficiently detect when elements enter or leave the viewport — lazy loading images, scroll animations, infinite scroll, and sticky headers.
observe() reports the element's state straight away. As soon as you start watching an element, the callback fires once with its current visibility, and for anything below the fold that's isIntersecting: false. Code that treats "not intersecting" as "the user scrolled away" (pausing a video, logging an analytics event, unloading content) runs on page load, for things the user never saw.
// #promo sits far below the fold
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) {
console.log("user scrolled away from", entry.target.id); // e.g. pause a video, send analytics
} else {
console.log("visible:", entry.target.id);
}
});
});
observer.observe(document.querySelector("#promo"));
// on load, before any scrolling:
// user scrolled away from promo <- the user never saw it
// (scroll down, then back up)
// visible: promo
// user scrolled away from promoWatch elements — without scroll events
The old way to detect if something is visible — listening to the scroll event
and calling getBoundingClientRect() every pixel — fires hundreds of times
per second and forces layout recalculation each time. Intersection Observer does the
same thing as part of the browser's own rendering work, and fires only when visibility actually changes.
Basic usage
// Create an observer with a callback and options:
const observer = new IntersectionObserver((entries) => {
// 'entries' = array of elements whose visibility changed
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(entry.target, "is now visible!");
} else {
console.log(entry.target, "is now hidden");
}
});
}, {
threshold: 0.1, // fire when 10% of element is visible
});
// Watch elements:
const section = document.querySelector("#hero");
observer.observe(section);
// Stop watching:
observer.unobserve(section);
// Stop all observations:
observer.disconnect();The basic example above logs "is now hidden" on load for the same reason. That's harmless for logging, but not for real actions. Only treat "not intersecting" as leaving once the element has actually been visible:
const seen = new WeakSet(); // elements that have actually been on screen
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
seen.add(entry.target);
console.log("visible:", entry.target.id);
} else if (seen.has(entry.target)) { // ignore the initial "not visible" report
console.log("user scrolled away from", entry.target.id);
}
});
});
observer.observe(document.querySelector("#promo"));
// on load: nothing
// visible: promo
// user scrolled away from promoLazy loading images (the main use case)
<!-- HTML: use data-src instead of src (placeholder prevents layout shift): -->
<img class="lazy" data-src="/real-image.jpg" src="/placeholder.jpg" alt="...">// JavaScript: load the real image when it's about to enter the viewport:
const imgObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return; // skip if not visible
const img = entry.target;
img.src = img.dataset.src; // swap to real URL
img.classList.remove("lazy");
imgObserver.unobserve(img); // done — stop watching this image
});
}, {
rootMargin: "200px 0px", // start loading 200px BEFORE the image enters viewport
});
// Watch all lazy images:
document.querySelectorAll("img.lazy").forEach(img => imgObserver.observe(img));Scroll animations
/* CSS: elements start invisible */
.fade-in {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}// JavaScript: add class when element enters viewport
const animObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
animObserver.unobserve(entry.target); // animate only once
}
});
}, { threshold: 0.15 });
document.querySelectorAll(".fade-in").forEach(el => animObserver.observe(el));Official Sources
- MDN Web Docs — Intersection Observer API
- W3C Intersection Observer Spec
- MDN Web Docs — ResizeObserver
- web.dev — Lazy loading images
The Codex links only to official documentation and the ECMAScript specification. No third-party tutorials.