JS Intersection Observer API: Complete Tutorial

A complete tutorial on the JavaScript Intersection Observer API. Covers IntersectionObserver constructor, root and rootMargin configuration, threshold arrays, observing and unobserving elements, entry properties like isIntersecting and intersectionRatio, lazy loading images, scroll-based animations, section tracking for navigation highlights, and building a visibility tracker utility.

JavaScriptintermediate
15 min read

The Intersection Observer API asynchronously detects when elements enter or leave the viewport (or any ancestor container). It replaces expensive scroll event listeners with a performant, callback-based approach. This guide covers every option, use case, and pattern.

Creating an Observer

javascriptjavascript
const observer = new IntersectionObserver(
  (entries, observer) => {
    entries.forEach((entry) => {
      console.log("Element:", entry.target.id);
      console.log("Is visible:", entry.isIntersecting);
      console.log("Intersection ratio:", entry.intersectionRatio);
    });
  },
  {
    root: null, // viewport (default)
    rootMargin: "0px", // margin around root
    threshold: 0, // trigger at 0% visibility
  }
);
 
// Start observing
const element = document.getElementById("my-section");
observer.observe(element);
 
// Stop observing
observer.unobserve(element);
 
// Stop all observations
observer.disconnect();

Constructor Options

OptionTypeDefaultDescription
rootElement or nullnull (viewport)The scrollable ancestor to use as the bounding box
rootMarginstring"0px"Margin around root (CSS format: "10px 20px 30px 40px")
thresholdnumber or array0Ratio(s) of visibility that trigger the callback

Threshold Examples

javascriptjavascript
// Single threshold: fires once at 0% visibility change
new IntersectionObserver(callback, { threshold: 0 });
 
// Fire at 50% visibility
new IntersectionObserver(callback, { threshold: 0.5 });
 
// Fire at every 25% step
new IntersectionObserver(callback, { threshold: [0, 0.25, 0.5, 0.75, 1.0] });
 
// Fire at every 10% step
const thresholds = Array.from({ length: 11 }, (_, i) => i / 10);
new IntersectionObserver(callback, { threshold: thresholds });

rootMargin for Pre-loading

javascriptjavascript
// Load content 200px before it enters the viewport
const preloadObserver = new IntersectionObserver(callback, {
  rootMargin: "200px 0px", // top/bottom: 200px, left/right: 0px
});
 
// Trigger 500px below viewport (for infinite scroll pre-fetch)
const infiniteObserver = new IntersectionObserver(callback, {
  rootMargin: "0px 0px 500px 0px", // 500px below viewport
});
 
// Shrink detection area (element must be 100px inside viewport)
const strictObserver = new IntersectionObserver(callback, {
  rootMargin: "-100px",
});

Entry Object Properties

PropertyTypeDescription
targetElementThe observed element
isIntersectingbooleanWhether the element is currently visible
intersectionRationumber0.0 to 1.0, how much is visible
intersectionRectDOMRectThe visible portion of the element
boundingClientRectDOMRectElement's bounding box
rootBoundsDOMRect or nullRoot element's bounding box
timenumberTimestamp when the intersection changed

Lazy Loading Images

javascriptjavascript
function lazyLoadImages() {
  const images = document.querySelectorAll("img[data-src]");
 
  const imageObserver = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (!entry.isIntersecting) return;
 
        const img = entry.target;
        img.src = img.dataset.src;
 
        if (img.dataset.srcset) {
          img.srcset = img.dataset.srcset;
        }
 
        img.removeAttribute("data-src");
        img.removeAttribute("data-srcset");
        img.classList.add("loaded");
 
        imageObserver.unobserve(img);
      });
    },
    {
      rootMargin: "200px 0px", // Pre-load 200px before viewport
      threshold: 0,
    }
  );
 
  images.forEach((img) => imageObserver.observe(img));
}
 
// HTML: <img data-src="photo.jpg" alt="Photo" width="600" height="400" />
lazyLoadImages();

Scroll-Based Animations

javascriptjavascript
function animateOnScroll() {
  const elements = document.querySelectorAll("[data-animate]");
 
  const animObserver = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const el = entry.target;
          const animation = el.dataset.animate;
          const delay = el.dataset.animateDelay || "0ms";
 
          el.style.transitionDelay = delay;
          el.classList.add("animate-visible", `animate-${animation}`);
 
          // Optionally unobserve for one-time animations
          if (!el.dataset.animateRepeat) {
            animObserver.unobserve(el);
          }
        } else {
          if (entry.target.dataset.animateRepeat) {
            entry.target.classList.remove(
              "animate-visible",
              `animate-${entry.target.dataset.animate}`
            );
          }
        }
      });
    },
    {
      threshold: 0.15,
      rootMargin: "0px 0px -50px 0px",
    }
  );
 
  elements.forEach((el) => {
    el.classList.add("animate-hidden");
    animObserver.observe(el);
  });
}
 
// HTML: <div data-animate="fade-up" data-animate-delay="200ms">Content</div>
animateOnScroll();

Active Section Tracking

Highlight the current section in a sidebar navigation as the user scrolls:

javascriptjavascript
function trackActiveSections(navSelector, sectionSelector) {
  const nav = document.querySelector(navSelector);
  const sections = document.querySelectorAll(sectionSelector);
 
  const sectionObserver = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        const id = entry.target.id;
        const link = nav.querySelector(`a[href="#${id}"]`);
 
        if (!link) return;
 
        if (entry.isIntersecting) {
          // Remove active from all links
          nav.querySelectorAll("a").forEach((a) => {
            a.classList.remove("active");
            a.removeAttribute("aria-current");
          });
 
          link.classList.add("active");
          link.setAttribute("aria-current", "true");
        }
      });
    },
    {
      rootMargin: "-20% 0px -60% 0px", // Middle 20% of viewport
      threshold: 0,
    }
  );
 
  sections.forEach((section) => sectionObserver.observe(section));
}
 
trackActiveSections("#sidebar-nav", "section[id]");

Visibility Tracker Utility

javascriptjavascript
class VisibilityTracker {
  constructor(options = {}) {
    this.observers = new Map();
    this.callbacks = new Map();
    this.defaultOptions = {
      root: null,
      rootMargin: "0px",
      threshold: 0,
      ...options,
    };
  }
 
  observe(element, callback, options = {}) {
    const opts = { ...this.defaultOptions, ...options };
    const key = this.getKey(opts);
 
    if (!this.observers.has(key)) {
      const observer = new IntersectionObserver((entries) => {
        entries.forEach((entry) => {
          const cb = this.callbacks.get(entry.target);
          if (cb) cb(entry);
        });
      }, opts);
 
      this.observers.set(key, observer);
    }
 
    this.callbacks.set(element, callback);
    this.observers.get(key).observe(element);
 
    // Return cleanup function
    return () => this.unobserve(element, key);
  }
 
  unobserve(element, key) {
    this.callbacks.delete(element);
 
    if (key) {
      const observer = this.observers.get(key);
      if (observer) observer.unobserve(element);
    } else {
      for (const observer of this.observers.values()) {
        observer.unobserve(element);
      }
    }
  }
 
  observeOnce(element, callback, options = {}) {
    return this.observe(
      element,
      (entry) => {
        if (entry.isIntersecting) {
          callback(entry);
          this.unobserve(element);
        }
      },
      options
    );
  }
 
  waitForVisible(element, options = {}) {
    return new Promise((resolve) => {
      this.observeOnce(element, resolve, options);
    });
  }
 
  getKey(options) {
    return JSON.stringify({
      root: options.root,
      rootMargin: options.rootMargin,
      threshold: options.threshold,
    });
  }
 
  disconnectAll() {
    for (const observer of this.observers.values()) {
      observer.disconnect();
    }
    this.observers.clear();
    this.callbacks.clear();
  }
}
 
// Usage
const tracker = new VisibilityTracker({ rootMargin: "100px" });
 
// One-time observation
tracker.observeOnce(document.getElementById("hero"), () => {
  console.log("Hero section became visible");
});
 
// Continuous observation
const cleanup = tracker.observe(document.getElementById("footer"), (entry) => {
  console.log("Footer visible:", entry.isIntersecting);
});
 
// Promise-based
await tracker.waitForVisible(document.getElementById("cta"));
console.log("CTA is now visible, showing animation");

For infinite scroll implementation, see implementing infinite scroll with JS observers.

Rune AI

Rune AI

Key Insights

  • Off-main-thread detection: Intersection Observer runs asynchronously in the browser engine, not blocking JavaScript execution like scroll listeners
  • rootMargin for pre-loading: Extend the detection area with positive margins to trigger lazy loading or animations before elements enter the viewport
  • Threshold arrays for granular control: Use [0, 0.25, 0.5, 0.75, 1.0] to fire callbacks at every 25% visibility step for progress-based animations
  • Unobserve after one-time events: Call observer.unobserve(entry.target) inside the callback for elements that only need one trigger (lazy load, animate once)
  • Share observers across elements: Create one observer per unique options set and observe multiple elements with it to minimize resource usage
RunePowered by Rune AI

Frequently Asked Questions

How is Intersection Observer better than scroll event listeners?

Scroll events fire on every pixel of scroll (potentially 60+ times per second), blocking the main thread. Intersection Observer uses browser-optimized detection that runs off the main thread. It fires callbacks only when visibility thresholds are crossed, not on every scroll frame.

Can I observe elements inside a scrollable container, not the viewport?

Yes. Set the `root` option to the scrollable container element. The observer will detect when child elements enter or leave that container's visible area instead of the viewport.

Why does my observer fire immediately when I observe an element?

The callback fires once immediately with the initial intersection state of all observed elements. Check `entry.isIntersecting` to determine if the element is actually visible. This immediate fire is by design so you know the initial state.

How many Intersection Observers should I create?

Create one observer per unique combination of `root`, `rootMargin`, and `threshold`. Multiple elements can share the same observer. Avoid creating a separate observer per element; instead, use the callback's `entry.target` to identify which element triggered.

Does Intersection Observer work with CSS transforms?

Yes. The API uses the element's bounding box after CSS transforms are applied. However, transforms on ancestor elements can affect the intersection calculation. The `rootBounds` property reflects the actual visible bounds of the root element.

Conclusion

The Intersection Observer API provides performant visibility detection without scroll event listeners. Use it for lazy loading images, scroll animations, section tracking, and infinite scroll triggers. Share observers across elements with the same options, unobserve elements after one-time events, and use rootMargin to pre-load content before it enters the viewport. For infinite scroll, see implementing infinite scroll with JS observers. For DOM change detection, see JavaScript Mutation Observer: complete tutorial.