Academic Block

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);

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 intersectionRatio when 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 observer
  • observe(target) / unobserve(target) control single elements
  • disconnect() stops everything
  • Entries give you target, isIntersecting, and intersectionRatio
  • threshold and rootMargin give 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.

📝 JavaScript Code
👁 Console Output

Click “Run Code” to see the output here.

Ctrl + Enter to run code Esc to close