
Many front-end features need to know whether an element is visible: lazy-loaded images, infinite scrolling, reveal animations, sticky navigation, and view tracking. You can build all of these with scroll events and getBoundingClientRect(), but it is easy to create too many calculations on every scroll.
IntersectionObserver gives the browser a better way to notify you when a target enters or leaves a viewport or scroll container.
What IntersectionObserver does
IntersectionObserver watches the intersection between a target element and a root area.
It is useful because it is:
- More efficient than manually checking every scroll event.
- Easier than repeatedly calculating element rectangles.
- Configurable through
root,rootMargin, andthreshold.
Common use cases include image lazy loading, infinite scroll, animation triggers, ad view tracking, and "section active" navigation.
Basic usage
Create an observer:
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
console.log(entry.isIntersecting)
})
})
Observe an element:
observer.observe(targetElement)
Stop observing:
observer.unobserve(targetElement)
observer.disconnect()
Use unobserve for one target. Use disconnect when the whole observer is no longer needed.
Options
const observer = new IntersectionObserver(callback, {
root: null,
rootMargin: "100px",
threshold: [0, 0.5, 1],
})
| Option | Meaning | Default |
|---|---|---|
root |
The viewport or scroll container used for checking visibility | null |
rootMargin |
Extra margin around the root, useful for triggering early | "0px" |
threshold |
Visibility ratio or ratios that trigger callbacks | 0 |
rootMargin: "100px" can start loading before the element actually reaches the viewport. threshold: 0.5 triggers when about half of the target is visible.
Callback entries
Each IntersectionObserverEntry includes useful data:
| Property | Meaning |
|---|---|
isIntersecting |
Whether the target currently intersects the root |
intersectionRatio |
Visible ratio from 0 to 1 |
boundingClientRect |
Target element rectangle |
rootBounds |
Root rectangle |
For most UI behavior, isIntersecting is enough. For analytics or progressive effects, use intersectionRatio and thresholds.
Lazy-load images
const images = document.querySelectorAll("img[data-src]")
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
const image = entry.target
image.src = image.dataset.src
observer.unobserve(image)
})
},
{ rootMargin: "100px" },
)
images.forEach((image) => observer.observe(image))
The positive rootMargin starts loading before the image reaches the viewport, reducing visible blank time.
Infinite scroll
Use a sentinel element near the end of the list:
const sentinel = document.querySelector("#load-more")
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMoreItems()
}
},
{ rootMargin: "50px" },
)
observer.observe(sentinel)
Add request guards so multiple callbacks do not trigger overlapping loads.
Reveal animations
const elements = document.querySelectorAll(".reveal")
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible")
observer.unobserve(entry.target)
}
})
})
elements.forEach((element) => observer.observe(element))
Pair this with transform and opacity animation when possible:
.reveal {
opacity: 0;
transform: translateY(16px);
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
transition: opacity 200ms ease, transform 200ms ease;
}
For the rendering side of that decision, read Reflow and Repaint: Why CSS Animations Get Janky.
IntersectionObserver vs scroll events
| Need | IntersectionObserver | Scroll event |
|---|---|---|
| Know whether a target is visible | Better default | Requires manual calculation |
| Trigger lazy loading | Better default | Possible but noisier |
| Track exact scroll position continuously | Not suitable | Better fit |
| Animate based on every scroll pixel | Not suitable | Use scroll-driven logic carefully |
Do not replace every scroll event with IntersectionObserver. Use it when your real question is visibility, not exact scroll distance.
Related FreeMac guides
- For animation performance, read Reflow and Repaint: Why CSS Animations Get Janky.
- For z-index and layers, read Why z-index Fails: Understanding CSS Stacking Context.
- For event loop behavior, read JavaScript Event Loop: Tasks, Microtasks, and Rendering.
Continue reading
Reflow and Repaint: Why CSS Animations Get Janky
Understand browser rendering steps, reflow, repaint, compositing, and why transform and opacity are usually better animation targets than width, height, top, or left.
8 min readJavaScript var vs let: Function Scope, Block Scope, and Closures
Understand the difference between var and let in JavaScript through function scope, block scope, for loops, closures, and why let avoids the classic 3 3 3 result.
8 min readTypeScript as vs satisfies: When to Use Each
Understand the difference between TypeScript as assertions and the satisfies operator: when to assert runtime knowledge, when to validate object structure, and how to preserve literal inference.
Subscribe to FreeMac
Weekly picks: free Mac software reviews, trusted source updates, alternatives, and low-friction guides.