JavaScriptBrowser APIs

IntersectionObserver

Watches whether an element is visible within the window or a scrollable container, and calls a function whenever that changes.

Returns
A new IntersectionObserver object.
On this page

Example

#
JavaScript
const feed = document.querySelector('#feed')
const lessons = feed.querySelectorAll('p')

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const state = entry.isIntersecting ? 'visible' : 'hidden'
    console.log(`${entry.target.textContent}: ${state}`)
  }
}, { root: feed })

lessons.forEach((lesson) => observer.observe(lesson))
console.log(`Observing ${lessons.length} lessons`)
Console
Observing 5 lessons
Variables: visible
Conditionals: visible
Loops: visible
Functions: hidden
Arrays: hidden

The first entry comes from synchronous code, and the lesson states arrive later, in the callback. “Loops” is only partly visible, but with the default threshold: 0 even a single pixel counts. Scroll the list in the preview, and the console will show further changes.

Definition and usage

#

IntersectionObserver tells you when an element enters or leaves the visible area. Instead of checking elements’ positions on every scroll, you create an observer with a callback function, and the browser calls it at the right moment on its own. This underlies lazy loading, infinite lists, scroll-triggered animations, and view counting.

The callback receives an array of IntersectionObserverEntry objects. The most important fields are target (the observed element), isIntersecting (whether the element is visible), and intersectionRatio (what fraction of the element is visible, from 0 to 1). The first call happens right after observe(), so you immediately learn the starting state.

You pass options as the constructor’s second argument. root is the element visibility is measured against (the browser window by default). rootMargin expands or shrinks that area, e.g. "200px" lets you start loading before the element appears on screen. threshold sets at what visible fraction to call the function, e.g. 0.5 or [0, 0.5, 1].

The observe() method adds an element to the observation, unobserve() removes it, and disconnect() ends the observation entirely. The callback runs asynchronously, so its console entries appear after synchronous code.

Syntax

#
Syntax
new IntersectionObserver(callback)
new IntersectionObserver(callback, options)

observer.observe(element)
observer.unobserve(element)
observer.disconnect()

Parameters

#
  • callback

    function (entries, observer)

    The function called when visibility changes. It receives an array of entries and the observer itself.
  • options

    optional object

    root (an element, or null for the window), rootMargin (a margin written like in CSS, e.g. "0px 0px 200px 0px"), and threshold (a number or an array of numbers from 0 to 1).

More examples

#
How much of the element is visible
JavaScript
const viewport = document.querySelector('#viewport')

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const percent = Math.round(entry.intersectionRatio * 100)
    console.log(`${entry.target.textContent}: ${percent}% visible`)
  }
}, { root: viewport, threshold: [0, 0.25, 0.5, 0.75, 1] })

viewport.querySelectorAll('.card').forEach((card) => observer.observe(card))
Console
HTML: 100% visible
CSS: 25% visible

The second card sticks out past the container, so only a quarter of it is visible. Scroll the container in the preview, and the observer will fire again each time the visible fraction crosses one of the threshold values.

Infinite task list
JavaScript
const list = document.querySelector('#list')
const sentinel = document.querySelector('#sentinel')
let page = 0

const observer = new IntersectionObserver(([entry]) => {
  if (!entry.isIntersecting) return
  page += 1

  for (let i = 1; i <= 5; i++) {
    const item = document.createElement('li')
    item.textContent = `Task ${(page - 1) * 5 + i}`
    sentinel.before(item)
  }
  console.log(`Loaded page ${page}`)

  if (page === 3) {
    observer.disconnect()
    sentinel.textContent = 'These are all the tasks'
  }
}, { root: list })

observer.observe(sentinel)
Console
Loaded page 1

A sentinel element sits at the end of the list. When it becomes visible, we add more tasks right before it. Scroll the list to the bottom in the preview to load further pages. After the third one, the observer disconnects.

Lazy loading images
JavaScript
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue
    const img = entry.target
    img.src = img.dataset.src
    observer.unobserve(img)
  }
}, { rootMargin: '200px' })

document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img))

Without the root option, visibility is measured against the browser window. An image only gets its real address once it’s 200px from the screen, and then stops being observed. For simple cases, the HTML attribute loading="lazy" achieves the same thing.

Browser support

#

Widely available · since 2019

Works in all modern browsers, phones included. You can use it without worry.

  • Chrome
  • Edge
  • Firefox
  • Safari

Good practices

#
  • After a one-time action, such as loading an image, call observer.unobserve(element). The observer won’t keep tracking the element for no reason.
  • A single observer can track many elements. Don’t create a separate observer for every item in a list.
  • For simple image lazy loading, the loading="lazy" HTML attribute is often enough. Use an observer when you need your own logic.

Related entries

#

Spotted a mistake or missing an example? Write to us.