# The Intersection Observer API

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

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-16 | Topics: [Web Platform](https://flaviocopes.com/tags/platform/) | Canonical: https://flaviocopes.com/intersection-observer/

The **Intersection Observer API** tells you when an element enters or leaves the viewport. You do not need a [scroll listener](https://flaviocopes.com/scrolling/) on `window`.

This one API replaced a whole family of hacks we used for years: lazy loading images, infinite scroll, "animate when visible" effects, ad viewability tracking, scrollspy navigation. All of them boil down to the same question: *is this element visible right now?*

In this tutorial we'll build all of those, and we'll look at the details that trip people up.

## Why scroll listeners are the wrong tool

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

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

This has two problems.

First, 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.

Second, `getBoundingClientRect()` 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.

```js
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.

```js
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:

- **`isIntersecting`** is `true` when the element crosses into the visible area. This is the property you'll use 90% of the time.
- **`target`** is the observed element. You need it because one callback serves many elements.
- **`intersectionRatio`** is how much of the element is visible, from `0` to `1`. A value of `0.5` means half the element is in view.
- **`boundingClientRect`** is the element's rectangle.
- **`intersectionRect`** is the visible portion of that rectangle.
- **`rootBounds`** is the rectangle of the root (the viewport, unless you changed it).
- **`time`** is a timestamp of when the change happened.

Here's something that surprises everyone the first time: **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.

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

Let's look at each one, because they all have details worth knowing.

### 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:

```js
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`:

```js
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.

- `0` (the default): the callback fires as soon as a single pixel enters or leaves.
- `1`: the callback fires when the *entire* element is visible.
- `0.5`: fires when half is visible.

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

```js
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:

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

Then swap it in when the image approaches the viewport:

```js
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))
```

Two details matter here.

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:

```html
<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.

```html
<ul id="results"></ul>
<div id="load-more"></div>
```

```js
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)
```

The sentinel trick is the whole pattern. We never measure scroll position. We never compute how far the user is from the bottom. The empty div at the end of the list does that for us: when it's visible, we're at 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](https://flaviocopes.com/fetch-api/) and some [DOM](https://flaviocopes.com/dom/) insertion and you're done.

When there are no more pages, stop watching:

```js
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.

```js
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))
```

```css
.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:

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

This works well with [CSS transitions](https://flaviocopes.com/css-transitions/).

## 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.

```js
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 interesting part is the `rootMargin`. Those negative 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:

```js
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:

```js
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 is the honest version of "impression tracking": at least half the banner, measured across every enter and exit.

## Cleanup

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

```js
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

A short list of things that cost me (and many others) debugging time:

- **The initial callback.** `observe()` triggers the callback once immediately with the current state. Check `isIntersecting` instead of assuming every call means "it just appeared".
- **`threshold: 1` and tall elements.** An element taller than the viewport never reaches ratio 1. The callback never fires.
- **`rootMargin` needs units.** `'0 0 200px 0'` fails silently in some browsers. Write `'0px 0px 200px 0px'`.
- **Hidden elements don't intersect.** An element with `display: none` reports `isIntersecting: false`. If you show it later, the observer picks it up on the next check.
- **Callbacks are not instant.** The browser batches intersection checks with rendering. If you need per-frame precision for an animation, this is the wrong tool; use [requestAnimationFrame](https://flaviocopes.com/requestanimationframe/) for that.

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.
