IntersectionObserver detects when an element enters or leaves the viewport (or another container) without the performance cost of manually polling getBoundingClientRect() on every scroll event.
1The Intersection Observer API | JavaScript Tutorial - In-Depth Guide Part 1
IntersectionObserver watches one or more elements and calls a callback whenever their visibility relative to a viewport (or ancestor) crosses a threshold β without you writing any scroll-event code.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) console.log('Visible!', entry.target);
});
});
observer.observe(document.querySelector('.card'));Efficient Visibility Detection
2The Intersection Observer API | JavaScript Tutorial - In-Depth Guide Part 2
The 'threshold' option controls what percentage of an element must be visible before the callback fires β from 0 (any pixel visible) to 1 (fully visible).
const observer = new IntersectionObserver(callback, {
threshold: 0.5, // fires when 50% visible
});The threshold Option
3The Intersection Observer API | JavaScript Tutorial - In-Depth Guide Part 3
'rootMargin' expands or shrinks the observed viewport area, letting you trigger the callback before an element is actually visible β perfect for pre-loading content just before it scrolls into view.
const observer = new IntersectionObserver(callback, {
rootMargin: '200px', // trigger 200px before entering view
});The rootMargin Option
4The Intersection Observer API | JavaScript Tutorial - In-Depth Guide Part 4
A classic use case: lazy-loading images by only setting their real 'src' once they scroll near the viewport, and disconnecting the observer once loaded.
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.src = entry.target.dataset.src;
obs.unobserve(entry.target); // done, stop watching
}
});
}, { rootMargin: '200px' });Lazy Loading Images
5The Intersection Observer API | JavaScript Tutorial - In-Depth Guide Part 5
Infinite scroll is another common pattern: observe a sentinel element at the bottom of a list, and load more content when it becomes visible.
const sentinel = document.querySelector('#load-more-trigger');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) loadNextPage();
});
observer.observe(sentinel);Infinite Scroll Pattern
6Step-by-Step Breakdown
IntersectionObserver watches one or more elements and calls a callback whenever their visibility relative to a viewport (or ancestor) crosses a threshold β without you writing any scroll-event code.
Checkpoint: Does IntersectionObserver require manually listening to scroll events?
- βYes, it still relies on a scroll listener internally that you write
- βNo, the browser handles visibility detection natively and efficiently
The 'threshold' option controls what percentage of an element must be visible before the callback fires β from 0 (any pixel visible) to 1 (fully visible).
'rootMargin' expands or shrinks the observed viewport area, letting you trigger the callback before an element is actually visible β perfect for pre-loading content just before it scrolls into view.
Checkpoint: What does a positive rootMargin value do to the effective observation area?
- βExpands it, triggering before the element is actually visible
- βShrinks it, requiring more of the element to be visible
A classic use case: lazy-loading images by only setting their real 'src' once they scroll near the viewport, and disconnecting the observer once loaded.
Infinite scroll is another common pattern: observe a sentinel element at the bottom of a list, and load more content when it becomes visible.
Next, we'll explore 'The Resize Observer'.
Level Up π
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Avoid Triggering Disorienting Scroll-In Animations for Reduced-Motion Users
When using IntersectionObserver to trigger scroll-in animations, check `window.matchMedia('(prefers-reduced-motion: reduce)')` and skip or simplify the animation for users who have indicated a preference for reduced motion.
SEO Implications
- 1
Lazy Loading Below-the-Fold Content Improves Core Web Vitals
Using IntersectionObserver to defer loading of off-screen images and content reduces initial page weight and improves Largest Contentful Paint, a factor in search ranking β but ensure lazy-loaded content critical to page content is still crawlable, e.g. via proper fallback or server-rendering.
Best Practices
Use IntersectionObserver Instead of Scroll Event Listeners for Visibility Checks
Manually computing getBoundingClientRect() on every scroll event is expensive and can cause visible jank; IntersectionObserver delegates this to an efficient, native browser implementation that runs off the main thread's critical path.
Unobserve Elements Once They No Longer Need Tracking
Calling unobserve() after a lazy-loaded image has loaded (or after a one-time animation trigger has fired) avoids unnecessary ongoing overhead for elements that have already served their purpose.
Frequent Bugs
Continuing to observe every lazy-loaded image indefinitely instead of unobserving after it loads, causing the observer to keep tracking hundreds of already-loaded, now-irrelevant elements.
Call `observer.unobserve(entry.target)` (or use the `once`-style pattern) immediately after handling the intersection for elements that only need a single trigger.
Setting threshold: 1 for a lazy-load trigger and being confused why it never fires for elements taller than the viewport, since they can never be 100% visible at once.
Use a lower threshold (like 0 or 0.1) for elements that are larger than the viewport, since they may never satisfy a high visibility percentage requirement.
Real-World Examples
Triggering Scroll-In Animations
A marketing page wanted content sections to fade in as the user scrolled them into view, without a heavy animation library or manual scroll-position math.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('fade-in-visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.2 });
document.querySelectorAll('.fade-in-section').forEach((el) => observer.observe(el));