Go Back

read

Published on: Jun 6, 2025

Programming

Change Language:

The Intersection Observer API

One of the first things that interested me when I started my web development journey was achieving the “appear on scroll” effect. Honestly, I’m not sure why, but I really needed to add it to every site I created.

Back then, we didn’t have the “IntersectionObserver API.” Instead, we had to rely on some questionable coding practices using the “getBoundingClientRect” API. Let me show you:

function is_element_in_viewport(el: HTMLElement): boolean {
  const rect = el.getBoundingClientRect();
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  );
}

const elements = document.querySelectorAll('.reveal');

function check_visibility() {
  let i = 0;
  while(i < elements.length) {
    if(is_element_in_viewport(elements[i])) {
      el.classList.add('visible')
    }
    i++;
  }
}

window.addEventListener('scroll', checkVisibility);
window.addEventListener('resize', checkVisibility);

check_visibility();

Although it’s pretty understandable, this is straight up bad for several reasons:

FeaturegetBoundingClientRect() + ScrollIntersectionObserver
CPU Performance❌ Can trigger on every scroll event, even for dozens/hundreds of elements✅ Highly optimized under the hood
Battery usage on mobile❌ Higher due to constant re-checking✅ Lower, uses async events
Customization⚠️ Harder to fine-tune (e.g., partial visibility)✅ Simple “threshold” and “rootMargin” options
Code cleanliness❌ Messy with listeners and cleanup✅ Encapsulated logic
Auto unobserve when needed❌ Manual tracking and cleanup required✅ Built-in with “.unobserve()“

What is IntersectionObserver?

The IntersectionObserver API allows developers to asynchronously observe changes in the intersection of a target element with an ancestor element or the viewport.

In simpler terms, it tells you when an element enters or leaves the visible area of the screen (or another container).

Why Use It?

Before “IntersectionObserver”, we relied on scroll events combined with manual calculations to determine an element’s position. This was not only cumbersome but also very inefficient for performance. And not gonna lie, some of my projects out there still use some of those, it’s not pretty.

Common Use Cases for IntersectionObserver:

  • Lazy-loading images or components
  • Triggering animations when elements come into view
  • Infinite scrolling lists
  • Reporting ad impressions
  • Observing visibility for analytics or accessibility

Making “IntersectionObserver” Performant

To get the most out of this API, let’s try to follow best practices:

Use a single observer instance where possible

Creating one “IntersectionObserver” and reusing it for multiple elements is more efficient than instantiating many observers.

I saw this code:


for (let i = 0; i < elements.length; i++) {
  const el = elements[i];
  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.classList.add('visible');
      }
    });
  }, { threshold: 0.1 });
  observer.observe(el);
}

This is of course bad news, the developer is creating a new observable instance for every element they want to observe, that’s too much!!

Use “rootMargin” and “threshold” wisely

  • “rootMargin” can be used to trigger intersection before an element is fully visible.
  • “threshold” helps fine-tune sensitivity for partial visibility.

Disconnect when done

Call “observer.unobserve()” or “observer.disconnect()” when elements no longer need observation (e.g., once they’ve loaded or animated).


A Clean and Reusable Observer with Closures

Here’s an example of a closure-based function I use across my projects to simplify setting up observers:

export type List = NodeListOf<HTMLElement> | Array<HTMLElement>

export default function (
    call_back: (entry: IntersectionObserverEntry) => void,
    options?: IntersectionObserverInit
) {
    const observer = new IntersectionObserver((entries) => {
        let i = 0;
        while (i < entries.length) {
            call_back(entries[i])
            i++;
        }
    }, options);

    return (nodes: List, optional_call_back?: (node: HTMLElement) => void) => {
        let j = 0;
        while (j < nodes.length) {
            if (optional_call_back) optional_call_back(nodes[j]);
            observer.observe(nodes[j]);
            j++;
        }
    };
}

Benefits of this pattern:

  1. Closure-based: The internal observer is scoped privately, making the API clean and free from side effects.
  2. Reusable: You define the callback logic once and apply it to many sets of elements.
  3. Customizable: Optional per-node callback for additional setup (e.g., adding a class or setting an attribute).
  4. DRY: Eliminates repeated boilerplate when setting up multiple observers throughout an app.
  5. Only one loop to both apply styles and observables!

Example Usage:

const observe = createObserver((entry) => {
    if (entry.isIntersecting) {
        entry.target.classList.add('visible');
    }
}, {threshold: 0.1});

const nodes = document.querySelectorAll('.reveal') as NodeListOf<HTMLElement>;
observe(nodes, (node) => node.classList.add('pre-reveal'));

Conclusion

The “IntersectionObserver” API is a powerful and efficient tool in the modern front-end toolkit. By using closures and abstractions like the example above, you can keep your codebase clean, performant, and reusable.

You may like: