JS Intersection Observer API: Complete Tutorial
A complete tutorial on the JavaScript Intersection Observer API. Covers IntersectionObserver constructor, root and rootMargin configuration, threshold arrays, observing and unobserving elements, entry properties like isIntersecting and intersectionRatio, lazy loading images, scroll-based animations, section tracking for navigation highlights, and building a visibility tracker utility.
The Intersection Observer API asynchronously detects when elements enter or leave the viewport (or any ancestor container). It replaces expensive scroll event listeners with a performant, callback-based approach. This guide covers every option, use case, and pattern.
Creating an Observer
const observer = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
console.log("Element:", entry.target.id);
console.log("Is visible:", entry.isIntersecting);
console.log("Intersection ratio:", entry.intersectionRatio);
});
},
{
root: null, // viewport (default)
rootMargin: "0px", // margin around root
threshold: 0, // trigger at 0% visibility
}
);
// Start observing
const element = document.getElementById("my-section");
observer.observe(element);
// Stop observing
observer.unobserve(element);
// Stop all observations
observer.disconnect();Constructor Options
| Option | Type | Default | Description |
|---|---|---|---|
root | Element or null | null (viewport) | The scrollable ancestor to use as the bounding box |
rootMargin | string | "0px" | Margin around root (CSS format: "10px 20px 30px 40px") |
threshold | number or array | 0 | Ratio(s) of visibility that trigger the callback |
Threshold Examples
// Single threshold: fires once at 0% visibility change
new IntersectionObserver(callback, { threshold: 0 });
// Fire at 50% visibility
new IntersectionObserver(callback, { threshold: 0.5 });
// Fire at every 25% step
new IntersectionObserver(callback, { threshold: [0, 0.25, 0.5, 0.75, 1.0] });
// Fire at every 10% step
const thresholds = Array.from({ length: 11 }, (_, i) => i / 10);
new IntersectionObserver(callback, { threshold: thresholds });rootMargin for Pre-loading
// Load content 200px before it enters the viewport
const preloadObserver = new IntersectionObserver(callback, {
rootMargin: "200px 0px", // top/bottom: 200px, left/right: 0px
});
// Trigger 500px below viewport (for infinite scroll pre-fetch)
const infiniteObserver = new IntersectionObserver(callback, {
rootMargin: "0px 0px 500px 0px", // 500px below viewport
});
// Shrink detection area (element must be 100px inside viewport)
const strictObserver = new IntersectionObserver(callback, {
rootMargin: "-100px",
});Entry Object Properties
| Property | Type | Description |
|---|---|---|
target | Element | The observed element |
isIntersecting | boolean | Whether the element is currently visible |
intersectionRatio | number | 0.0 to 1.0, how much is visible |
intersectionRect | DOMRect | The visible portion of the element |
boundingClientRect | DOMRect | Element's bounding box |
rootBounds | DOMRect or null | Root element's bounding box |
time | number | Timestamp when the intersection changed |
Lazy Loading Images
function lazyLoadImages() {
const images = document.querySelectorAll("img[data-src]");
const imageObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src;
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
img.removeAttribute("data-src");
img.removeAttribute("data-srcset");
img.classList.add("loaded");
imageObserver.unobserve(img);
});
},
{
rootMargin: "200px 0px", // Pre-load 200px before viewport
threshold: 0,
}
);
images.forEach((img) => imageObserver.observe(img));
}
// HTML: <img data-src="photo.jpg" alt="Photo" width="600" height="400" />
lazyLoadImages();Scroll-Based Animations
function animateOnScroll() {
const elements = document.querySelectorAll("[data-animate]");
const animObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target;
const animation = el.dataset.animate;
const delay = el.dataset.animateDelay || "0ms";
el.style.transitionDelay = delay;
el.classList.add("animate-visible", `animate-${animation}`);
// Optionally unobserve for one-time animations
if (!el.dataset.animateRepeat) {
animObserver.unobserve(el);
}
} else {
if (entry.target.dataset.animateRepeat) {
entry.target.classList.remove(
"animate-visible",
`animate-${entry.target.dataset.animate}`
);
}
}
});
},
{
threshold: 0.15,
rootMargin: "0px 0px -50px 0px",
}
);
elements.forEach((el) => {
el.classList.add("animate-hidden");
animObserver.observe(el);
});
}
// HTML: <div data-animate="fade-up" data-animate-delay="200ms">Content</div>
animateOnScroll();Active Section Tracking
Highlight the current section in a sidebar navigation as the user scrolls:
function trackActiveSections(navSelector, sectionSelector) {
const nav = document.querySelector(navSelector);
const sections = document.querySelectorAll(sectionSelector);
const sectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const id = entry.target.id;
const link = nav.querySelector(`a[href="#${id}"]`);
if (!link) return;
if (entry.isIntersecting) {
// Remove active from all links
nav.querySelectorAll("a").forEach((a) => {
a.classList.remove("active");
a.removeAttribute("aria-current");
});
link.classList.add("active");
link.setAttribute("aria-current", "true");
}
});
},
{
rootMargin: "-20% 0px -60% 0px", // Middle 20% of viewport
threshold: 0,
}
);
sections.forEach((section) => sectionObserver.observe(section));
}
trackActiveSections("#sidebar-nav", "section[id]");Visibility Tracker Utility
class VisibilityTracker {
constructor(options = {}) {
this.observers = new Map();
this.callbacks = new Map();
this.defaultOptions = {
root: null,
rootMargin: "0px",
threshold: 0,
...options,
};
}
observe(element, callback, options = {}) {
const opts = { ...this.defaultOptions, ...options };
const key = this.getKey(opts);
if (!this.observers.has(key)) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const cb = this.callbacks.get(entry.target);
if (cb) cb(entry);
});
}, opts);
this.observers.set(key, observer);
}
this.callbacks.set(element, callback);
this.observers.get(key).observe(element);
// Return cleanup function
return () => this.unobserve(element, key);
}
unobserve(element, key) {
this.callbacks.delete(element);
if (key) {
const observer = this.observers.get(key);
if (observer) observer.unobserve(element);
} else {
for (const observer of this.observers.values()) {
observer.unobserve(element);
}
}
}
observeOnce(element, callback, options = {}) {
return this.observe(
element,
(entry) => {
if (entry.isIntersecting) {
callback(entry);
this.unobserve(element);
}
},
options
);
}
waitForVisible(element, options = {}) {
return new Promise((resolve) => {
this.observeOnce(element, resolve, options);
});
}
getKey(options) {
return JSON.stringify({
root: options.root,
rootMargin: options.rootMargin,
threshold: options.threshold,
});
}
disconnectAll() {
for (const observer of this.observers.values()) {
observer.disconnect();
}
this.observers.clear();
this.callbacks.clear();
}
}
// Usage
const tracker = new VisibilityTracker({ rootMargin: "100px" });
// One-time observation
tracker.observeOnce(document.getElementById("hero"), () => {
console.log("Hero section became visible");
});
// Continuous observation
const cleanup = tracker.observe(document.getElementById("footer"), (entry) => {
console.log("Footer visible:", entry.isIntersecting);
});
// Promise-based
await tracker.waitForVisible(document.getElementById("cta"));
console.log("CTA is now visible, showing animation");For infinite scroll implementation, see implementing infinite scroll with JS observers.
Rune AI
Key Insights
- Off-main-thread detection: Intersection Observer runs asynchronously in the browser engine, not blocking JavaScript execution like scroll listeners
- rootMargin for pre-loading: Extend the detection area with positive margins to trigger lazy loading or animations before elements enter the viewport
- Threshold arrays for granular control: Use
[0, 0.25, 0.5, 0.75, 1.0]to fire callbacks at every 25% visibility step for progress-based animations - Unobserve after one-time events: Call
observer.unobserve(entry.target)inside the callback for elements that only need one trigger (lazy load, animate once) - Share observers across elements: Create one observer per unique options set and observe multiple elements with it to minimize resource usage
Frequently Asked Questions
How is Intersection Observer better than scroll event listeners?
Can I observe elements inside a scrollable container, not the viewport?
Why does my observer fire immediately when I observe an element?
How many Intersection Observers should I create?
Does Intersection Observer work with CSS transforms?
Conclusion
The Intersection Observer API provides performant visibility detection without scroll event listeners. Use it for lazy loading images, scroll animations, section tracking, and infinite scroll triggers. Share observers across elements with the same options, unobserve elements after one-time events, and use rootMargin to pre-load content before it enters the viewport. For infinite scroll, see implementing infinite scroll with JS observers. For DOM change detection, see JavaScript Mutation Observer: complete tutorial.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.