Intersection Observer Get Certified
The Intersection Observer API lets you efficiently detect when an element enters or leaves the viewport (or another container). It is the modern, performant way to implement lazy loading, infinite scroll, and scroll-triggered animations.
Basic Usage
What this code does: Creates an observer and watches a target element for visibility changes.
function createMockIntersectionObserver(callback) {
const observed = [];
return {
observe(target) {
observed.push(target);
console.log("Now observing:", target.label);
},
simulate(target, isIntersecting) {
if (observed.includes(target)) {
callback([{ target, isIntersecting, intersectionRatio: isIntersecting ? 1 : 0 }]);
}
}
};
}
const observer = createMockIntersectionObserver((entries) => {
entries.forEach(entry => {
console.log(entry.target.label, "isIntersecting:", entry.isIntersecting);
});
});
const box = { label: "hero-image" };
observer.observe(box);
observer.simulate(box, true);
observer.observe(el).target, isIntersecting, intersectionRatio — same fields the browser provides.Lazy Loading Images
What this code does: Loads an image only when it becomes visible, then stops observing it.
function createMockIntersectionObserver(callback) {
const observed = [];
return {
observe(target) { observed.push(target); },
unobserve(target) {
const i = observed.indexOf(target);
if (i > -1) observed.splice(i, 1);
console.log("Stopped observing:", target.label);
},
simulate(target, isIntersecting) {
if (observed.includes(target)) {
callback([{ target, isIntersecting, intersectionRatio: isIntersecting ? 1 : 0 }]);
}
}
};
}
const observer = createMockIntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log("Loading image:", entry.target.label);
observer.unobserve(entry.target);
}
});
});
const img = { label: "product-photo.jpg" };
observer.observe(img);
observer.simulate(img, true);
Observing Multiple Elements
What this code does: Watches several elements and logs which ones are currently visible.
function createMockIntersectionObserver(callback) {
const observed = [];
return {
observe(target) { observed.push(target); },
simulate(target, isIntersecting) {
if (observed.includes(target)) {
callback([{ target, isIntersecting, intersectionRatio: isIntersecting ? 1 : 0 }]);
}
}
};
}
const observer = createMockIntersectionObserver((entries) => {
entries.forEach(entry => {
console.log(entry.target.label, "→", entry.isIntersecting ? "visible" : "hidden");
});
});
const card1 = { label: "card-1" };
const card2 = { label: "card-2" };
observer.observe(card1);
observer.observe(card2);
observer.simulate(card1, true);
observer.simulate(card2, false);
Scroll-Triggered Animation
What this code does: Triggers a fade-in or fade-out when a section enters or leaves the viewport.
function createMockIntersectionObserver(callback) {
const observed = [];
return {
observe(target) { observed.push(target); },
simulate(target, isIntersecting) {
if (observed.includes(target)) {
callback([{ target, isIntersecting, intersectionRatio: isIntersecting ? 1 : 0 }]);
}
}
};
}
const observer = createMockIntersectionObserver((entries) => {
entries.forEach(entry => {
console.log(entry.target.label, entry.isIntersecting ? "→ fade in" : "→ fade out");
});
});
const section = { label: "about-section" };
observer.observe(section);
observer.simulate(section, true);
observer.simulate(section, false);
Stopping Observation with unobserve()
What this code does: Stops watching a single element after the first time it becomes visible.
function createMockIntersectionObserver(callback) {
const observed = [];
return {
observe(target) { observed.push(target); },
unobserve(target) {
const i = observed.indexOf(target);
if (i > -1) observed.splice(i, 1);
console.log("Stopped observing:", target.label);
},
simulate(target, isIntersecting) {
if (observed.includes(target)) {
callback([{ target, isIntersecting, intersectionRatio: isIntersecting ? 1 : 0 }]);
}
}
};
}
const observer = createMockIntersectionObserver((entries) => {
entries.forEach(entry => {
console.log("Update for", entry.target.label, "- isIntersecting:", entry.isIntersecting);
});
});
const banner = { label: "promo-banner" };
observer.observe(banner);
observer.simulate(banner, true);
observer.unobserve(banner);
observer.simulate(banner, false); // this will be ignored
console.log("No further updates after unobserve");
Disconnecting the Whole Observer
What this code does: Stops watching every element at once with disconnect().
function createMockIntersectionObserver(callback) {
const observed = [];
return {
observe(target) { observed.push(target); },
disconnect() {
observed.length = 0;
console.log("Observer disconnected");
},
simulate(target, isIntersecting) {
if (observed.includes(target)) {
callback([{ target, isIntersecting, intersectionRatio: isIntersecting ? 1 : 0 }]);
}
}
};
}
const observer = createMockIntersectionObserver((entries) => {
entries.forEach(entry => console.log("Visible:", entry.target.label));
});
const itemA = { label: "item-a" };
const itemB = { label: "item-b" };
observer.observe(itemA);
observer.observe(itemB);
observer.disconnect();
observer.simulate(itemA, true);
observer.simulate(itemB, true);
console.log("Done");
The threshold Option
What this code does: Fires the callback at specific visibility percentages using threshold.
function createMockIntersectionObserver(callback, options) {
options = options || {};
console.log("Configured thresholds:", options.threshold);
const observed = [];
return {
observe(target) { observed.push(target); },
simulate(target, ratio) {
if (observed.includes(target)) {
callback([{ target, isIntersecting: ratio > 0, intersectionRatio: ratio }]);
}
}
};
}
const observer = createMockIntersectionObserver(
(entries) => {
entries.forEach(entry => {
console.log(entry.target.label, "ratio:", entry.intersectionRatio);
});
},
{ threshold: [0, 0.5, 1] }
);
const chart = { label: "sales-chart" };
observer.observe(chart);
observer.simulate(chart, 0);
observer.simulate(chart, 0.5);
observer.simulate(chart, 1);
The rootMargin Option
What this code does: Explains how rootMargin expands the intersection area for early triggering.
console.log("rootMargin expands or shrinks the area used for intersection.");
console.log("Example: rootMargin: '200px' triggers 200px before the element enters the viewport.");
console.log("Useful for preloading content just before it becomes visible.");
IntersectionObserver vs Manual Scroll Listening
| Feature | IntersectionObserver | scroll + getBoundingClientRect |
|---|---|---|
| Fires on every scroll pixel? | No — only on real visibility changes | Yes (unless throttled) |
| Performance impact | Low | Can be high |
| Multiple thresholds | Built-in | Manual calculation |
| Code complexity | Low | Higher |
Common Mistakes
- Forgetting to call
unobserve()after a one-time action - Never calling
disconnect()when the component is removed - Assuming the callback runs synchronously after
observe() - Ignoring
intersectionRatiowhen you need precise visibility
Best Practices
- Prefer IntersectionObserver over manual scroll listeners
- Call
unobserve()after one-time actions (lazy load, etc.) - Call
disconnect()on cleanup - Use threshold arrays for progressive animations
- Use rootMargin to preload content early
Quick Summary
new IntersectionObserver(callback, options)creates the observerobserve(target)/unobserve(target)control single elementsdisconnect()stops everything- Entries give you
target,isIntersecting, andintersectionRatio thresholdandrootMargingive fine control
Try It Yourself
What this code does: Observes a gallery at multiple thresholds and unobserves it after loading.
function createMockIntersectionObserver(callback, options) {
options = options || {};
const observed = [];
return {
observe(target) {
observed.push(target);
console.log("Observing:", target.label);
},
unobserve(target) {
const i = observed.indexOf(target);
if (i > -1) observed.splice(i, 1);
},
simulate(target, ratio) {
if (observed.includes(target)) {
callback([{ target, isIntersecting: ratio > 0, intersectionRatio: ratio }]);
}
}
};
}
const observer = createMockIntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log("Loading:", entry.target.label, "at ratio", entry.intersectionRatio);
observer.unobserve(entry.target);
}
});
},
{ threshold: [0.25, 0.75] }
);
const gallery = { label: "photo-gallery" };
observer.observe(gallery);
observer.simulate(gallery, 0.25);
observer.simulate(gallery, 0.75);
Tip: Use IntersectionObserver for any visibility-based logic. Always unobserve or disconnect when you no longer need the observer.
🧪 Test Your JavaScript Code
Edit the code on the left and click “Run Code” to see the console output on the right.
Click “Run Code” to see the output here.
Code Explanation
The complete example stays visible. Use Previous and Next to move through its logical blocks.
Select Next to begin exploring the code.