Skip to content

Intersection Observer — Detecting Element Visibility in the Viewport

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about Intersection Observer. We cover key concepts, practical examples, and best practices to help you master this topic.

Intersection Observer efficiently detects when elements enter or exit the viewport, enabling custom Lazy Loading, Infinite Scroll, and visibility-based animations.

What You'll Learn

By the end of this tutorial, you'll understand how Intersection Observer works, how to configure thresholds and root margins, how to implement custom lazy loading, and how to avoid common performance pitfalls.

Why It Matters

Before Intersection Observer, detecting element visibility required scroll event listeners that triggered layout thrashing and poor performance. Intersection Observer provides a declarative, efficient, callback-based API that runs off the main thread.

Real-World Use

A social media feed uses Intersection Observer to lazy load profile images as users scroll. The observer detects when images enter the viewport, swaps the placeholder src for the real image, and unobserve the element to stop monitoring.

Intersection Observer Flow

graph TD
    A[Create Observer] --> B[Configure options
root, rootMargin, threshold] B --> C[Observe
target elements] C --> D[Browser tracks
element visibility] D --> E{Element intersects
viewport?} E -->|No| F[Keep observing] E -->|Yes| G[Callback fires
with entry data] G --> H[Load resource
or trigger action] H --> I[Unobserve element] I --> J[Stop tracking
this element] style A fill:#4a90d9,color:#fff style D fill:#e67e22,color:#fff style G fill:#f39c12,color:#fff style H fill:#27ae60,color:#fff

Basic Intersection Observer

// Create the observer
const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target;
            img.src = img.dataset.src;
            img.classList.remove('lazy');
            observer.unobserve(img);
            console.log(`Loaded: ${img.dataset.src}`);
        }
    });
}, {
    root: null,          // Use viewport as root
    rootMargin: '200px', // Start loading 200px before visible
    threshold: 0.01      // Trigger when 1% visible
});

// Observe all lazy images
document.querySelectorAll('img[data-src]').forEach(img => {
    observer.observe(img);
});

Advanced Configuration

// 1. Multiple thresholds for progress tracking
const progressObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        // entry.intersectionRatio gives 0.0 to 1.0
        const visibility = Math.round(entry.intersectionRatio * 100);

        if (entry.intersectionRatio > 0.5) {
            console.log(`Element is more than 50% visible: ${visibility}%`);
        }

        // Update a progress indicator
        entry.target.style.opacity = entry.intersectionRatio;
    });
}, {
    threshold: [0, 0.25, 0.5, 0.75, 1] // Trigger at each 25% step
});

// 2. Root margin for pre-loading
const preloadObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            // Load content before user scrolls to it
            loadContent(entry.target.dataset.contentId);
            preloadObserver.unobserve(entry.target);
        }
    });
}, {
    rootMargin: '500px 0px', // 500px above and below viewport
    threshold: 0
});

// 3. Element-level root (scrollable container)
const container = document.querySelector('.scrollable-list');
const containerObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            loadMoreItems();
        }
    });
}, {
    root: container,
    rootMargin: '100px',
    threshold: 0
});

Custom Lazy Loading with Intersection Observer

// Full-featured lazy loading utility
class LazyLoader {
    constructor(options = {}) {
        this.options = {
            root: options.root || null,
            rootMargin: options.rootMargin || '200px 0px',
            threshold: options.threshold || 0.01,
            onLoad: options.onLoad || null
        };

        this.observer = new IntersectionObserver(
            (entries) => this.handleIntersect(entries),
            this.options
        );

        this.loadedCount = 0;
        this.totalCount = 0;
    }

    observe(element, loadCallback) {
        this.totalCount++;
        element.dataset.lazyId = `lazy-${this.totalCount}`;
        element._onLoad = loadCallback;
        this.observer.observe(element);
    }

    handleIntersect(entries) {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                const element = entry.target;

                // Call the load function
                if (typeof element._onLoad === 'function') {
                    element._onLoad(element, () => {
                        this.loadedCount++;
                        this.observer.unobserve(element);
                        if (this.options.onLoad) {
                            this.options.onLoad(element, this.loadedCount, this.totalCount);
                        }
                    });
                }

                this.observer.unobserve(element);
            }
        });
    }

    disconnect() {
        this.observer.disconnect();
    }

    getStats() {
        return {
            loaded: this.loadedCount,
            total: this.totalCount,
            remaining: this.totalCount - this.loadedCount
        };
    }
}

// Usage
const lazyLoader = new LazyLoader({
    rootMargin: '300px',
    onLoad: (element, loaded, total) => {
        console.log(`Loaded ${loaded}/${total} elements`);
    }
});

document.querySelectorAll('.lazy-image').forEach(img => {
    lazyLoader.observe(img, (element, done) => {
        element.src = element.dataset.src;
        element.onload = () => {
            element.classList.add('loaded');
            done();
        };
    });
});

Infinite Scroll Implementation

// Infinite scroll with Intersection Observer
class InfiniteScroll {
    constructor(options) {
        this.loadMore = options.loadMore;
        this.container = options.container;
        this.sentinel = options.sentinel;

        this.observer = new IntersectionObserver(
            (entries) => this.handleIntersect(entries),
            { rootMargin: '100px' }
        );

        this.observer.observe(this.sentinel);
        this.isLoading = false;
    }

    async handleIntersect(entries) {
        const entry = entries[0];
        if (!entry.isIntersecting || this.isLoading) return;

        this.isLoading = true;
        this.sentinel.classList.add('loading');

        try {
            await this.loadMore();
            console.log('Loaded more items');
        } catch (err) {
            console.error('Failed to load more:', err);
        } finally {
            this.isLoading = false;
            this.sentinel.classList.remove('loading');
        }
    }

    disconnect() {
        this.observer.disconnect();
    }
}

// Usage
const sentinel = document.getElementById('scroll-sentinel');
const container = document.getElementById('content-container');

const infiniteScroll = new InfiniteScroll({
    container,
    sentinel,
    loadMore: async () => {
        const response = await fetch('/api/items?page=' + currentPage++);
        const items = await response.json();

        items.forEach(item => {
            const div = document.createElement('div');
            div.textContent = item.title;
            container.appendChild(div);
        });
    }
});

Common Mistakes

  1. Creating a new observer for every element. One observer can monitor hundreds of elements. Creating multiple observers wastes resources.
  2. Forgetting to unobserve after loading. Observed elements continue to trigger callbacks. Always unobserve after the action is complete.
  3. Using scroll events instead of Intersection Observer. Scroll events trigger on every pixel scrolled. Intersection Observer fires only at thresholds — much more efficient.
  4. Not checking entry.isIntersecting. The callback fires for both entering and exiting. Check isIntersecting to distinguish.
  5. Setting rootMargin too large. A 5000px rootMargin loads content way before needed. Use 200-500px for most cases.

Practice Questions

  1. How does Intersection Observer differ from scroll event listeners?
  2. What do rootMargin and threshold control in Intersection Observer?
  3. When should you unobserve an element after intersection?
  4. How do you implement infinite scroll with Intersection Observer?
  5. What happens if you don't call unobserve after the element is loaded?

Challenge: Build a performance comparison between scroll event-based lazy loading and Intersection Observer: measure scroll handler call count, CPU time, and frame drops during scrolling for each approach.

FAQ

Does Intersection Observer work on all browsers?

Yes, all modern browsers support Intersection Observer. For IE11, use the polyfill from the WICG repository.

Can Intersection Observer detect element-to-element intersection?

Yes. Set the root option to a scrollable container element. The observer detects when children intersect with the container.

What is the performance impact of Intersection Observer?

Minimal. The observer runs off the main thread and only fires callbacks at specified thresholds. It's much more efficient than scroll events.

Can I change observer options after creation?

No. Options are set at creation time. To change options, disconnect the old observer and create a new one.

How many elements can one observer handle?

Hundreds or thousands. One observer can efficiently monitor many elements. The limit depends on the browser and device.

Mini Project

Build a custom lazy loading library using Intersection Observer: support images, iframes, background images, and video elements. Include configurable rootMargin, a loading spinner, error handling for failed loads, and a real-time performance dashboard.

What's Next

You've mastered Intersection Observer. Now apply it to Image Lazy Loading for comprehensive image deferral strategies.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro