Learning Objectives

  • Understand IntersectionObserver API
  • Implement lazy loading for images
  • Create infinite scroll functionality
  • Trigger animations on scroll
  • Apply observer best practices

What is IntersectionObserver?

IntersectionObserver provides an efficient way to detect when elements enter or leave the viewport. It's more performant than scroll event listeners because it runs asynchronously and doesn't block the main thread.

Basic Usage

// Create observer with callback
const observer = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            console.log('Element is visible!');
            console.log('Target:', entry.target);
            console.log('Intersection ratio:', entry.intersectionRatio);
        } else {
            console.log('Element is not visible');
        }
    });
});

// Observe element
const element = document.querySelector('#myElement');
observer.observe(element);

// Observe multiple elements
document.querySelectorAll('.observe-me').forEach(el => {
    observer.observe(el);
});

// Stop observing specific element
observer.unobserve(element);

// Disconnect all observations
observer.disconnect();

Observer Options

const options = {
    // Root element (null = viewport)
    root: null,
    
    // Margin around root (can be negative)
    rootMargin: '0px 0px -100px 0px', // top right bottom left
    
    // Threshold(s) to trigger callback
    // 0 = any pixel visible
    // 1 = 100% visible
    // [0, 0.5, 1] = trigger at 0%, 50%, 100%
    threshold: 0.5
};

const observer = new IntersectionObserver(callback, options);

Entry Properties

const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        // The observed element
        console.log(entry.target);
        
        // Is element intersecting with root?
        console.log(entry.isIntersecting); // true/false
        
        // How much is visible (0-1)
        console.log(entry.intersectionRatio); // 0.5 = 50%
        
        // Rectangles
        console.log(entry.intersectionRect);  // Visible portion
        console.log(entry.boundingClientRect); // Element bounds
        console.log(entry.rootBounds);        // Root bounds
        
        // Timestamp
        console.log(entry.time);
    });
});

Real-World Examples

Lazy Loading Images

// HTML: <img data-src="image.jpg" alt="Description">

const imageObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target;
            
            // Load the image
            img.src = img.dataset.src;
            
            // Add loaded class
            img.classList.add('loaded');
            
            // Stop observing this image
            imageObserver.unobserve(img);
        }
    });
}, {
    rootMargin: '50px' // Start loading 50px before visible
});

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

Infinite Scroll

// HTML: <div id="sentinel"></div> at bottom of list

let page = 1;
let loading = false;

const sentinel = document.querySelector('#sentinel');

const infiniteObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting && !loading) {
            loadMoreContent();
        }
    });
}, {
    rootMargin: '100px' // Load before reaching bottom
});

infiniteObserver.observe(sentinel);

async function loadMoreContent() {
    loading = true;
    
    try {
        const response = await fetch(`/api/posts?page=${page}`);
        const data = await response.json();
        
        if (data.posts.length === 0) {
            infiniteObserver.disconnect();
            return;
        }
        
        renderPosts(data.posts);
        page++;
    } catch (error) {
        console.error('Failed to load:', error);
    } finally {
        loading = false;
    }
}

function renderPosts(posts) {
    const container = document.querySelector('#posts');
    posts.forEach(post => {
        const div = document.createElement('div');
        div.className = 'post';
        div.innerHTML = `
            

${post.title}

${post.excerpt}

`; container.insertBefore(div, sentinel); }); }

Scroll Animations

const animateObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            // Add animation class
            entry.target.classList.add('animate-in');
            
            // Stop observing (animate once)
            animateObserver.unobserve(entry.target);
        }
    });
}, {
    threshold: 0.5, // 50% visible
    rootMargin: '0px 0px -100px 0px' // Trigger earlier
});

// Observe all elements with animation class
document.querySelectorAll('.animate-on-scroll').forEach(el => {
    animateObserver.observe(el);
});

Sticky Header Detection

const header = document.querySelector('header');
const sentinel = document.querySelector('#header-sentinel');

const stickyObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (!entry.isIntersecting) {
            header.classList.add('sticky');
        } else {
            header.classList.remove('sticky');
        }
    });
}, {
    threshold: 0
});

stickyObserver.observe(sentinel);

Best Practices

1. Unobserve after loading
if (entry.isIntersecting) {
    loadImage(entry.target);
    observer.unobserve(entry.target); // Stop observing
}
2. Use rootMargin for early loading
const observer = new IntersectionObserver(callback, {
    rootMargin: '100px' // Load 100px before visible
});
3. Set appropriate thresholds
// Trigger at multiple points
threshold: [0, 0.25, 0.5, 0.75, 1]

// Trigger when fully visible
threshold: 1.0
4. Disconnect when done
// Clean up
observer.disconnect();

Key Takeaways