Content
The Intersection Observer API
One of the first things that interested me when I started my web development journey was achieving the “appear on scroll” effect. Honestly, I’m not sure why, but I really needed to add it to every site I created.
Back then, we didn’t have the “IntersectionObserver API.” Instead, we had to rely on some questionable coding practices using the “getBoundingClientRect” API. Let me show you:
function is_element_in_viewport(el: HTMLElement): boolean {
const rect = el.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
const elements = document.querySelectorAll('.reveal');
function check_visibility() {
let i = 0;
while(i < elements.length) {
if(is_element_in_viewport(elements[i])) {
el.classList.add('visible')
}
i++;
}
}
window.addEventListener('scroll', checkVisibility);
window.addEventListener('resize', checkVisibility);
check_visibility();
Although it’s pretty understandable, this is straight up bad for several reasons:
Feature | getBoundingClientRect() + Scroll | IntersectionObserver |
---|---|---|
CPU Performance | ❌ Can trigger on every scroll event, even for dozens/hundreds of elements | ✅ Highly optimized under the hood |
Battery usage on mobile | ❌ Higher due to constant re-checking | ✅ Lower, uses async events |
Customization | ⚠️ Harder to fine-tune (e.g., partial visibility) | ✅ Simple “threshold” and “rootMargin” options |
Code cleanliness | ❌ Messy with listeners and cleanup | ✅ Encapsulated logic |
Auto unobserve when needed | ❌ Manual tracking and cleanup required | ✅ Built-in with “.unobserve()“ |
What is IntersectionObserver?
The IntersectionObserver API allows developers to asynchronously observe changes in the intersection of a target element with an ancestor element or the viewport.
In simpler terms, it tells you when an element enters or leaves the visible area of the screen (or another container).
Why Use It?
Before “IntersectionObserver”, we relied on scroll events combined with manual calculations to determine an element’s position. This was not only cumbersome but also very inefficient for performance. And not gonna lie, some of my projects out there still use some of those, it’s not pretty.
Common Use Cases for IntersectionObserver:
- Lazy-loading images or components
- Triggering animations when elements come into view
- Infinite scrolling lists
- Reporting ad impressions
- Observing visibility for analytics or accessibility
Making “IntersectionObserver” Performant
To get the most out of this API, let’s try to follow best practices:
Use a single observer instance where possible
Creating one “IntersectionObserver” and reusing it for multiple elements is more efficient than instantiating many observers.
I saw this code:
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}, { threshold: 0.1 });
observer.observe(el);
}
This is of course bad news, the developer is creating a new observable instance for every element they want to observe, that’s too much!!
Use “rootMargin” and “threshold” wisely
- “rootMargin” can be used to trigger intersection before an element is fully visible.
- “threshold” helps fine-tune sensitivity for partial visibility.
Disconnect when done
Call “observer.unobserve()” or “observer.disconnect()” when elements no longer need observation (e.g., once they’ve loaded or animated).
A Clean and Reusable Observer with Closures
Here’s an example of a closure-based function I use across my projects to simplify setting up observers:
export type List = NodeListOf<HTMLElement> | Array<HTMLElement>
export default function (
call_back: (entry: IntersectionObserverEntry) => void,
options?: IntersectionObserverInit
) {
const observer = new IntersectionObserver((entries) => {
let i = 0;
while (i < entries.length) {
call_back(entries[i])
i++;
}
}, options);
return (nodes: List, optional_call_back?: (node: HTMLElement) => void) => {
let j = 0;
while (j < nodes.length) {
if (optional_call_back) optional_call_back(nodes[j]);
observer.observe(nodes[j]);
j++;
}
};
}
Benefits of this pattern:
- Closure-based: The internal observer is scoped privately, making the API clean and free from side effects.
- Reusable: You define the callback logic once and apply it to many sets of elements.
- Customizable: Optional per-node callback for additional setup (e.g., adding a class or setting an attribute).
- DRY: Eliminates repeated boilerplate when setting up multiple observers throughout an app.
- Only one loop to both apply styles and observables!
Example Usage:
const observe = createObserver((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
}, {threshold: 0.1});
const nodes = document.querySelectorAll('.reveal') as NodeListOf<HTMLElement>;
observe(nodes, (node) => node.classList.add('pre-reveal'));
Conclusion
The “IntersectionObserver” API is a powerful and efficient tool in the modern front-end toolkit. By using closures and abstractions like the example above, you can keep your codebase clean, performant, and reusable.