IntersectionObserver - Observes HTML elements when they are visible via JavaScript scrolling.

Video thumbnail

The IntersectionObserver is one of those JavaScript APIs that, when you discover it, you wonder how you survived using only scroll events. In my case, it allowed me to automate something I always did manually: detecting which heading is visible in a post and activating the corresponding link in the table of contents.
Since then, I haven't looked back.

In this guide, I explain how it works, how to configure it correctly, and how to apply it to a real-world internal navigation case (scroll-spy) using the exact same pattern I use in my projects.

I'm going to explain a very interesting method known as Intersection Observer in JavaScript. Basically, it allows me to implement this type of behavior: adding an observer over a list of IDs in a viewer and marking a link as activated based on a heading that is visible in the viewport.

What is IntersectionObserver and why does it make visibility detection easier

IntersectionObserver is an API that allows you to execute a function when an element enters or leaves the viewport (or any container you define).
And it does so without using constant scroll events, which means:

  • better performance,
  • cleaner code,
  • and more accurate detections.

Before, you had to measure positions with getBoundingClientRect() and that was repeated on every scroll. Now the browser does the dirty work for you.

What can you detect with it?

  • If an image is about to appear → lazy-loading.
  • If a block is already visible → animations or analytics.
  • If an element is 100% inside the view → activating a section.
  • If a heading enters at 60% visibility → as I do, activating an index link.

Key Concepts: root, rootMargin, threshold, and entries

Although the API is simple, it's worth internalizing these four concepts because they are the foundation of everything.

root

It is the container whose visibility you will use as a reference.

  • If it is null: the browser's viewport.
  • If you define it: any scrollable div.

rootMargin

It is like CSS margin but applied to the visible area of the root.
It allows you to "advance" or "delay" the detection zone.

I, for example, use rootMargin: '0px 0px -60% 0px' because I want a heading to be considered visible when it is already further up the viewport, not just when it appears.

threshold

It is the percentage of visibility that the element must reach for the callback to fire.
It can be:

  • a number (0.6 for example),
  • or an array of numbers ([0, 0.25, 0.5, 1]).

For example:

  • threshold: 1 → only when the element is totally visible
  • threshold: 0 → as soon as it touches the edge
  • threshold: 0.6 → this is the case I usually use

entries

It is an array that your callback receives every time there are changes.
Each entry contains:

  • entry.target: observed element
  • entry.isIntersecting: whether it is intersecting
  • entry.intersectionRatio: visible percentage
  • entry.boundingClientRect
  • entry.intersectionRect
  • entry.rootBounds

It allows you to decide exactly what you want to do when an element crosses your target zone.

How does Intersection Observer work?

1. Define the options

const options = { root: null, rootMargin: '0px', threshold: 0.5, };

2. Create the observer

const observer = new IntersectionObserver((entries) => {
 entries.forEach(entry => {
   if (entry.isIntersecting) {
     console.log(entry.target.id);
   }
 });
}, options);

3. Select the elements

const elementos = document.querySelectorAll('.observado');

4. Observe them

elementos.forEach(el => observer.observe(el));

Practical example: activating links according to the visible heading

This is exactly the case where I use this API the most.
When I write an article with many <h2> and <h3> headings, it's great for me to detect which one is visible and activate the equivalent link in the side table of contents.

1. Select the headings of the post

const headings = Array.from(
 document.querySelectorAll('.post h2, .post h3')
);

This ensures that all my main headings are monitored for what the book viewer is.

2. Adjust the visibility zone

This is where my practical experience comes in:

In my projects, I consider a heading to be "active" when 60% of it has already passed through the viewport, so I use a negative rootMargin to move the trigger upwards.

I consider it observable when at least 60% of the content is visible in the viewport:

const observerOptions = {
  rootMargin: '0px 0px -60% 0px', // Detectar cuando el título está más arriba
};

You can adjust that value to what works best for you.

Select the elements to observe:

const headings = Array.from(document.querySelectorAll('.post h2, .post h3'));

3. Create the observer

In my HTML document (for example, in a post), I want to observe the h2 and h3 elements.

That is, when one of those headings reaches that 60% visibility, the desired code is executed.

Then, we iterate over those elements with a forEach and apply the .observe() to start monitoring them:

const observer = new IntersectionObserver((entries) => {

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

      // Mark the link as active
      this.hxs.forEach(link => {
        if (link.id === `${id}`) {
          // TO DO
        }
        
      });
    }
  });
}, observerOptions);

In my case:

  • I check if there are entries in the entries array.
  • If there are, I get the id of the heading.
  • I look for the equivalent link.
    • With that id, I do what I need.
  • I apply an active style to it.

4. Observe each heading

Before using it, we need to register the observer. So far, we have only defined:

headings.forEach(h => observer.observe(h));

Why does this technique work so well?

  • It avoids flickering.
  • It doesn't depend on scroll, but on real visibility.
  • It is ultra efficient.
  • It detects headings even if they jump quickly through the viewport.

Advanced tips and best practices

  • Avoid observing too many elements unnecessarily
    • The API is efficient, but don't overdo it.
    • Observe only what you really need.
  • Use threshold arrays when you want smooth animations
    • If you need to detect dynamic visibility increments:
    • threshold: [0, 0.1, 0.2, ..., 1]
  • Be careful with isIntersecting
    • In some browsers, it doesn't behave the same if the threshold does not include 0.
    • If you want total precision when an element is completely visible, it is better to use intersectionRatio.
  • Do not use IntersectionObserver for complex logic on every frame
    • If you need pixel-by-pixel animations, use requestAnimationFrame() instead.

Frequently Asked Questions

  • Does IntersectionObserver consume less than listening for scroll?
    • Yes, much less. Detection does not depend on continuous events.
  • Can I observe many elements at once?
    • Yes. A single observer can handle hundreds of elements.
  • What is the difference between threshold and intersectionRatio?
    • threshold is when you want the callback to fire.
      intersectionRatio is the actual visible percentage at any given moment.
  • Is it useful for a scroll-spy menu?
    • Yes, in fact, it is my favorite practical case.

Conclusion

IntersectionObserver is an extremely powerful and very undervalued tool as its use is not very friendly and it is somewhat complex to understand and abstract; although I hope this guide allows you to understand it more easily and be able to see its potential and use it in your projects.

For my particular case, using it to activate links according to the visible heading has greatly simplified the navigation of my articles: zero calculations, zero scroll listeners, maximum precision.

I agree to receive announcements of interest about this Blog.

"What is IntersectionObserver in JavaScript? Explained clearly," Smart Scrolling, The API that will change the way you scroll the web.

| 👤 Andrés Cruz

🇪🇸 En español