The Intersection Observer API

By

An in-depth tutorial on the Intersection Observer API: entries, thresholds, rootMargin, lazy loading, infinite scroll, scrollspy, visibility tracking, and the pitfalls to avoid.

~~~

The Intersection Observer API tells you when an element enters or leaves the viewport. You do not need a scroll listener on window.

We used scroll-based hacks for years to lazy load images, implement infinite scroll, animate visible elements, track ad viewability, and update scrollspy navigation. Each use case needs to know whether an element is visible.

In this tutorial we’ll build all of them and cover the common pitfalls.

Why scroll listeners are the wrong tool

Before Intersection Observer, we answered “is it visible?” like this:

window.addEventListener('scroll', () => {
  const rect = photo.getBoundingClientRect()
  if (rect.top < window.innerHeight) {
    loadPhoto()
  }
})

Scroll events fire many times per second. Your callback runs on every single one, on the main thread, while the user is scrolling. That’s exactly when the browser is busiest.

getBoundingClientRect() also forces the browser to recalculate layout. Calling it inside a scroll handler is a classic cause of janky scrolling.

Intersection Observer flips the model. You tell the browser what you want to know (“tell me when 25% of this element is visible”), and the browser tells you when it happens. The visibility calculation happens off the main thread. Your callback only runs when something you care about changed.

Your first observer

You create an observer with a callback. Then you call observe() on the element you want to watch.

const target = document.querySelector('#hero')

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      console.log('Hero is visible')
    }
  })
})

observer.observe(target)

One observer can watch many elements. Call observe() once per element, and the same callback handles all of them. This is cheaper than creating one observer per element, so prefer it when the elements share the same options.

document.querySelectorAll('.card').forEach((card) => {
  observer.observe(card)
})

Understanding the entries

The callback receives an array of entries, one for each observed element whose intersection state changed. Each entry carries everything you need:

The callback fires immediately when you call observe(), once per element, to report the initial state. If the element starts outside the viewport, you get an entry with isIntersecting: false right away. Don’t treat that first call as “the user scrolled”.

The options

You can pass a second argument to the constructor with three options.

const observer = new IntersectionObserver(callback, {
  root: null,
  rootMargin: '200px',
  threshold: 0.25
})

root

root is the element whose bounds act as the visible area. The default (null) is the viewport.

Set it when you watch elements inside a scrollable container:

const list = document.querySelector('.chat-messages')

const observer = new IntersectionObserver(callback, {
  root: list
})

Now “visible” means “visible inside .chat-messages”, not “visible in the page”. The root must be an ancestor of the elements you observe.

rootMargin

rootMargin grows (or shrinks) the root’s box before the intersection is computed. It uses the same syntax as CSS margin:

rootMargin: '200px'              // all four sides
rootMargin: '200px 0px'          // vertical, horizontal
rootMargin: '0px 0px 300px 0px'  // top, right, bottom, left

A positive margin makes the observer fire early, before the element actually reaches the viewport. That’s exactly what you want for lazy loading: start fetching the image 200px before the user sees the empty space.

A negative margin does the opposite. rootMargin: '-100px' means the element must be 100px inside the viewport before it counts as intersecting. Useful when you want an animation to trigger only once the element is comfortably on screen.

Be careful with the units: values must be in px or %, and the unit is required even for zero in some browsers. Write '0px 0px 300px 0px', not '0 0 300px 0'.

threshold

threshold is how much of the element must be visible for the callback to fire.

You can also pass an array. The callback then fires every time visibility crosses any of those values:

threshold: [0, 0.25, 0.5, 0.75, 1]

This gives you a coarse progress signal as the element scrolls through the viewport, without a scroll listener. We’ll use it later for visibility tracking.

One trap: threshold: 1 never fires for an element taller than the viewport. The whole element can never be visible at once, so the ratio never reaches 1. If your trigger mysteriously doesn’t fire on mobile, this is often why. Use a lower threshold, or threshold: 0 with a negative rootMargin.

Lazy loading images

Say you have a photo gallery with dozens of images below the fold. You only want to load them when the user scrolls close.

Put the real URL in a data-src attribute so the browser doesn’t fetch it upfront:

<img data-src="/photos/dolomites.jpg" alt="Dolomites at sunrise" width="800" height="600">

Then swap it in when the image approaches the viewport:

const images = document.querySelectorAll('img[data-src]')

const observer = new IntersectionObserver((entries, obs) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return

    const img = entry.target
    img.src = img.dataset.src
    obs.unobserve(img)
  })
}, { rootMargin: '200px' })

images.forEach((img) => observer.observe(img))

We call unobserve() after loading. The image has its source now, there’s nothing left to watch. Without this, the callback keeps firing every time the image scrolls in and out.

The rootMargin: '200px' starts the download 200px early, so the image is usually there by the time the user reaches it.

Note that for plain image lazy loading, the browser now does this natively:

<img src="/photos/dolomites.jpg" loading="lazy" alt="Dolomites at sunrise">

If loading="lazy" covers your case, use it. Reach for Intersection Observer when you need more control: loading a component, swapping a video poster, starting an expensive render.

Infinite scroll

Place a sentinel element at the bottom of your list. When it becomes visible, fetch the next page.

<ul id="results"></ul>
<div id="load-more"></div>
const sentinel = document.querySelector('#load-more')
let page = 1

const observer = new IntersectionObserver(async (entries) => {
  if (!entries[0].isIntersecting) return

  page = page + 1
  const response = await fetch(`/api/results?page=${page}`)
  const items = await response.json()
  appendToList(items)
})

observer.observe(sentinel)

We never measure the scroll position or calculate the distance from the bottom. When the empty div at the end of the list becomes visible, we know we reached the bottom.

Appending new rows pushes the sentinel back down, out of the viewport, so the observer naturally re-arms for the next page. Pair this with fetch and some DOM insertion and you’re done.

When there are no more pages, stop watching:

if (items.length === 0) {
  observer.unobserve(sentinel)
}

Animate elements when they scroll into view

Add a CSS class when an element enters the viewport. Remove it when it leaves if you want the animation to replay.

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    entry.target.classList.toggle('visible', entry.isIntersecting)
  })
}, { threshold: 0.2 })

document.querySelectorAll('.fade-in').forEach((el) => observer.observe(el))
.fade-in {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.4s, transform 0.4s;
}

.fade-in.visible {
  opacity: 1;
  transform: translateY(0);
}

The threshold: 0.2 makes the animation start when a fifth of the element is visible, which reads better than firing on the very first pixel.

If you want the animation to run only once, unobserve after the first intersection instead of toggling:

if (entry.isIntersecting) {
  entry.target.classList.add('visible')
  observer.unobserve(entry.target)
}

This works well with CSS transitions.

When the animation should follow scroll progress instead of a visibility threshold, use scroll-driven CSS animations.

Scrollspy: highlight the current section in a nav

Documentation sites highlight the table of contents entry for the section you’re reading. That’s an intersection problem too.

const links = document.querySelectorAll('nav a')

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return

    links.forEach((link) => {
      const matches = link.hash === `#${entry.target.id}`
      link.classList.toggle('active', matches)
    })
  })
}, { rootMargin: '-40% 0px -55% 0px' })

document.querySelectorAll('article section[id]').forEach((section) => {
  observer.observe(section)
})

The negative rootMargin values shrink the detection area to a narrow horizontal band around the upper-middle of the screen. A section counts as “current” only while it crosses that band. Without this, multiple sections intersect the viewport at once and the highlight jumps around.

Pause a video when it leaves the viewport

Autoplaying videos should stop when the user scrolls past them:

const video = document.querySelector('video')

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      video.play()
    } else {
      video.pause()
    }
  })
}, { threshold: 0.5 })

observer.observe(video)

With threshold: 0.5 the video plays only while at least half of it is on screen.

Track how long an element was visible

Analytics teams ask questions like “did the user actually see the signup banner, and for how long?”. A threshold array plus timestamps answers it:

let visibleSince = null
let totalVisible = 0

const observer = new IntersectionObserver((entries) => {
  const entry = entries[0]

  if (entry.intersectionRatio >= 0.5 && visibleSince === null) {
    visibleSince = entry.time
  }

  if (entry.intersectionRatio < 0.5 && visibleSince !== null) {
    totalVisible = totalVisible + (entry.time - visibleSince)
    visibleSince = null
  }
}, { threshold: [0, 0.5, 1] })

observer.observe(document.querySelector('#signup-banner'))

We use entry.time instead of Date.now() because it marks when the intersection actually changed, not when the callback ran.

This counts an impression while at least half the banner is visible, measured across every enter and exit.

Cleanup

Call unobserve(element) when one target is done. Call disconnect() when you tear down the whole observer:

observer.unobserve(target)
observer.disconnect()

My advice is to always disconnect observers you create in components or single-page apps. The observer holds references to its targets, and leftover observers keep firing after your component is gone. In a framework, create the observer when the component mounts and disconnect it in the cleanup function.

Pitfalls worth remembering

Keep these pitfalls in mind:

Browser support is universal at this point, every browser you target has it. There’s also an Intersection Observer v2 proposal that adds trackVisibility to detect when an element is covered by other content, but it only exists in Chromium, so don’t build on it.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about platform: