IntersectionObserver powers lazy-loaded images, infinite scroll, and "viewed" analytics by telling you when an element enters or leaves the viewport — efficiently, off the main thread. On older browsers you fake it the old-fashioned way: listen to scroll and resize, measure each watched element with getBoundingClientRect(), and fire when its on-screen status flips. Building that fallback shows exactly what the native API hides.
Implement intersectionObserverPolyfill(callback, options) returning an observer with observe, unobserve, and disconnect, matching the real API's shape. See MDN: IntersectionObserver.
function intersectionObserverPolyfill(callback, options) {
return { observe, unobserve, disconnect };
}
// callback(entries, observer); entry =
// { target, isIntersecting, intersectionRatio, boundingClientRect }
const io = intersectionObserverPolyfill((entries) => {
for (const e of entries) if (e.isIntersecting) load(e.target);
});
io.observe(image); // fires an initial entry immediately
// ...user scrolls; when `image` enters the viewport, the callback runs again
io.disconnect(); // detaches scroll/resize listeners
(0, 0, window.innerWidth, window.innerHeight); an element intersects when its rect overlaps it.isIntersecting; only report the ones that flipped on a given scroll/resize.observe(el) delivers one entry right away with the element's current state, like the native observer.intersectionRatio — the fraction of the element's area that's visible (0 when off-screen, 1 when fully in view). Clean up listeners in disconnect.We'll keep a map of watched elements to their last visibility, re-measure on scroll and resize, and report only the ones that changed.
Native IntersectionObserver watches elements against the viewport and calls you when their visibility changes — without you polling. To fake it, we poll: every scroll and resize we look at each watched element's rectangle (getBoundingClientRect()) and decide whether it overlaps the viewport box. To avoid spamming the callback, we remember each element's last state and only report the ones that just crossed the edge.
The viewport is a rectangle from (0, 0) to (innerWidth, innerHeight). An element intersects it when its rect overlaps — its bottom is below the top edge, its top is above the bottom edge, and likewise horizontally. Each element carries a remembered isIntersecting; a scroll re-measures and compares.
The naive version just checks a single edge — "is the top within the viewport?":
function ioNaive(cb) {
const els = new Set();
function check() {
for (const el of els) {
const { top } = el.getBoundingClientRect();
cb([{ target: el, isIntersecting: top < window.innerHeight }]);
}
}
return {
observe(el) { els.add(el); window.addEventListener('scroll', check); },
disconnect() { window.removeEventListener('scroll', check); },
};
}
Three problems. It calls the callback on every scroll even when nothing changed. It only checks top < innerHeight, so an element scrolled far above the viewport (negative bottom) still reads as "intersecting". And there's no ratio, no initial delivery, no per-element listener bookkeeping. We need a real overlap test plus change detection.
function intersectionObserverPolyfill(callback, options) {
const targets = new Map(); // el -> last isIntersecting (undefined until first read)
let listening = false;
function measure(el) {
const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
// clamp the element to the viewport on each axis, then multiply the overlaps
const ix = Math.max(0, Math.min(rect.right, vw) - Math.max(rect.left, 0));
const iy = Math.max(0, Math.min(rect.bottom, vh) - Math.max(rect.top, 0));
const interArea = ix * iy;
const area = rect.width * rect.height;
return {
target: el,
isIntersecting: interArea > 0,
intersectionRatio: area > 0 ? interArea / area : 0,
boundingClientRect: rect,
};
}
function check() {
const entries = [];
for (const [el, last] of targets) {
const entry = measure(el);
if (entry.isIntersecting !== last) { // only report flips
targets.set(el, entry.isIntersecting);
entries.push(entry);
}
}
if (entries.length) callback(entries, observer);
}
function start() {
if (listening) return;
listening = true;
window.addEventListener('scroll', check, true); // capture: catch nested scrollers
window.addEventListener('resize', check);
}
function stop() {
if (!listening) return;
listening = false;
window.removeEventListener('scroll', check, true);
window.removeEventListener('resize', check);
}
const observer = {
observe(el) {
if (targets.has(el)) return;
targets.set(el, undefined);
start();
const entry = measure(el); // deliver an initial entry now
targets.set(el, entry.isIntersecting);
callback([entry], observer);
},
unobserve(el) {
targets.delete(el);
if (targets.size === 0) stop();
},
disconnect() {
targets.clear();
stop();
},
};
return observer;
}
module.exports = { intersectionObserverPolyfill };
The upgrades over the naive version: a true two-axis overlap test (clamp each side to the viewport and multiply), change detection via the targets map so the callback only fires on flips, an initial entry delivered from observe, and listener bookkeeping that attaches on the first observe and detaches in disconnect.
observe(image) where image sits below the fold (rect top 900, viewport height 600):
targets.set(image, undefined), attach listeners.measure → iy = min(950, 600) − max(900, 0) = 600 − 900 = −50 → 0, so interArea = 0, isIntersecting = false. Store false, call callback([{ isIntersecting: false, … }]).image's rect top is now 200. scroll fires → check:
measure → iy = min(250, 600) − max(200, 0) = 50, interArea > 0, isIntersecting = true.true !== false → push the entry, update the map, callback([entry]).isIntersecting stays true, no flip, no callback.top < innerHeight marks elements scrolled above the viewport as visible. Test overlap on both axes: clamp and check the product is positive.observe so consumers don't wait for the first scroll.observe, and remove them in disconnect (and when the last element is unobserved), or scroll handlers pile up.threshold: [0, 0.5, 1]); track which thresholds were crossed, not just a boolean.root and rootMargin — measure against a scroll container instead of the viewport, and grow/shrink the box by the margins.requestAnimationFrame throttling — coalesce bursts of scroll events into one measurement per frame; the native version is already async and off-thread.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
IntersectionObserver powers lazy-loaded images, infinite scroll, and "viewed" analytics by telling you when an element enters or leaves the viewport — efficiently, off the main thread. On older browsers you fake it the old-fashioned way: listen to scroll and resize, measure each watched element with getBoundingClientRect(), and fire when its on-screen status flips. Building that fallback shows exactly what the native API hides.
Implement intersectionObserverPolyfill(callback, options) returning an observer with observe, unobserve, and disconnect, matching the real API's shape. See MDN: IntersectionObserver.
function intersectionObserverPolyfill(callback, options) {
return { observe, unobserve, disconnect };
}
// callback(entries, observer); entry =
// { target, isIntersecting, intersectionRatio, boundingClientRect }
const io = intersectionObserverPolyfill((entries) => {
for (const e of entries) if (e.isIntersecting) load(e.target);
});
io.observe(image); // fires an initial entry immediately
// ...user scrolls; when `image` enters the viewport, the callback runs again
io.disconnect(); // detaches scroll/resize listeners
(0, 0, window.innerWidth, window.innerHeight); an element intersects when its rect overlaps it.isIntersecting; only report the ones that flipped on a given scroll/resize.observe(el) delivers one entry right away with the element's current state, like the native observer.intersectionRatio — the fraction of the element's area that's visible (0 when off-screen, 1 when fully in view). Clean up listeners in disconnect.We'll keep a map of watched elements to their last visibility, re-measure on scroll and resize, and report only the ones that changed.
Native IntersectionObserver watches elements against the viewport and calls you when their visibility changes — without you polling. To fake it, we poll: every scroll and resize we look at each watched element's rectangle (getBoundingClientRect()) and decide whether it overlaps the viewport box. To avoid spamming the callback, we remember each element's last state and only report the ones that just crossed the edge.
The viewport is a rectangle from (0, 0) to (innerWidth, innerHeight). An element intersects it when its rect overlaps — its bottom is below the top edge, its top is above the bottom edge, and likewise horizontally. Each element carries a remembered isIntersecting; a scroll re-measures and compares.
The naive version just checks a single edge — "is the top within the viewport?":
function ioNaive(cb) {
const els = new Set();
function check() {
for (const el of els) {
const { top } = el.getBoundingClientRect();
cb([{ target: el, isIntersecting: top < window.innerHeight }]);
}
}
return {
observe(el) { els.add(el); window.addEventListener('scroll', check); },
disconnect() { window.removeEventListener('scroll', check); },
};
}
Three problems. It calls the callback on every scroll even when nothing changed. It only checks top < innerHeight, so an element scrolled far above the viewport (negative bottom) still reads as "intersecting". And there's no ratio, no initial delivery, no per-element listener bookkeeping. We need a real overlap test plus change detection.
function intersectionObserverPolyfill(callback, options) {
const targets = new Map(); // el -> last isIntersecting (undefined until first read)
let listening = false;
function measure(el) {
const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
// clamp the element to the viewport on each axis, then multiply the overlaps
const ix = Math.max(0, Math.min(rect.right, vw) - Math.max(rect.left, 0));
const iy = Math.max(0, Math.min(rect.bottom, vh) - Math.max(rect.top, 0));
const interArea = ix * iy;
const area = rect.width * rect.height;
return {
target: el,
isIntersecting: interArea > 0,
intersectionRatio: area > 0 ? interArea / area : 0,
boundingClientRect: rect,
};
}
function check() {
const entries = [];
for (const [el, last] of targets) {
const entry = measure(el);
if (entry.isIntersecting !== last) { // only report flips
targets.set(el, entry.isIntersecting);
entries.push(entry);
}
}
if (entries.length) callback(entries, observer);
}
function start() {
if (listening) return;
listening = true;
window.addEventListener('scroll', check, true); // capture: catch nested scrollers
window.addEventListener('resize', check);
}
function stop() {
if (!listening) return;
listening = false;
window.removeEventListener('scroll', check, true);
window.removeEventListener('resize', check);
}
const observer = {
observe(el) {
if (targets.has(el)) return;
targets.set(el, undefined);
start();
const entry = measure(el); // deliver an initial entry now
targets.set(el, entry.isIntersecting);
callback([entry], observer);
},
unobserve(el) {
targets.delete(el);
if (targets.size === 0) stop();
},
disconnect() {
targets.clear();
stop();
},
};
return observer;
}
module.exports = { intersectionObserverPolyfill };
The upgrades over the naive version: a true two-axis overlap test (clamp each side to the viewport and multiply), change detection via the targets map so the callback only fires on flips, an initial entry delivered from observe, and listener bookkeeping that attaches on the first observe and detaches in disconnect.
observe(image) where image sits below the fold (rect top 900, viewport height 600):
targets.set(image, undefined), attach listeners.measure → iy = min(950, 600) − max(900, 0) = 600 − 900 = −50 → 0, so interArea = 0, isIntersecting = false. Store false, call callback([{ isIntersecting: false, … }]).image's rect top is now 200. scroll fires → check:
measure → iy = min(250, 600) − max(200, 0) = 50, interArea > 0, isIntersecting = true.true !== false → push the entry, update the map, callback([entry]).isIntersecting stays true, no flip, no callback.top < innerHeight marks elements scrolled above the viewport as visible. Test overlap on both axes: clamp and check the product is positive.observe so consumers don't wait for the first scroll.observe, and remove them in disconnect (and when the last element is unobserved), or scroll handlers pile up.threshold: [0, 0.5, 1]); track which thresholds were crossed, not just a boolean.root and rootMargin — measure against a scroll container instead of the viewport, and grow/shrink the box by the margins.requestAnimationFrame throttling — coalesce bursts of scroll events into one measurement per frame; the native version is already async and off-thread.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.