The Intersection Observer API
By Flavio Copes
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.
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:
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.
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:
isIntersectingistruewhen the element crosses into the visible area. This is the property you’ll use 90% of the time.targetis the observed element. You need it because one callback serves many elements.intersectionRatiois how much of the element is visible, from0to1. A value of0.5means half the element is in view.boundingClientRectis the element’s rectangle.intersectionRectis the visible portion of that rectangle.rootBoundsis the rectangle of the root (the viewport, unless you changed it).timeis 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.
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:
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.
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:
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))
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:
<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)
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 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.
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 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:
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 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:
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. CheckisIntersectinginstead of assuming every call means “it just appeared”. threshold: 1and tall elements. An element taller than the viewport never reaches ratio 1. The callback never fires.rootMarginneeds units.'0 0 200px 0'fails silently in some browsers. Write'0px 0px 200px 0px'.- Hidden elements don’t intersect. An element with
display: nonereportsisIntersecting: 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 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.
Related posts about platform: