Intersection Observer API: Complete Guide
Beginner-friendly Intersection Observer API Guide based on MDN Docs
Contents
- 🎯 What is Intersection Observer API?
- Why Was It Created?
- 📚 Core Concepts (From MDN)
- 1. Root Element
- 2. Intersection Ratio
- 3. Thresholds
- 🔧 Complete Syntax
- Constructor
- Callback Function
- Options Object
- Methods
- 🎯 Real-World Use Cases (From MDN)
- 1. Lazy Loading Images ⭐ Most Common
- 2. Infinite Scroll
- 3. Ad Visibility Tracking
- 4. Scroll Animations
- 5. Active Navigation Highlighting
- 6. Video Auto-Play/Pause
- 7. Analytics - Track What Users See
- 💡 Best Practices
- 1. Always Unobserve When Done
- 2. Clean Up in React/Vue Components
- 3. Use rootMargin for Preloading
- 4. Handle Multiple Thresholds Efficiently
- 5. Consider Page Visibility API for Ads
- 6. Use trackVisibility for Real Visibility
- 7. Reuse Observers
- 8. Check isIntersecting First
- 9. Handle Errors Gracefully
- 10. Optimize Threshold Arrays
- 🚀 Advanced Patterns
- Lazy Load with Blur Effect
- Pause Expensive Animations
- Progressive Image Loading
- 📊 Performance Comparison
- Old Scroll Listener vs Intersection Observer
- 🌐 Browser Support
- 📖 Summary
- Advanced Concept: rootMargin, threshold & intersectionRatio
- Part 1: rootMargin and threshold Together
- Understanding Each Separately
- Why Use Them Together?
- Example 1: Lazy Load Images Early
- Example 2: Trigger After Element is Fully In View
- Example 3: Different Margins for Different Sides
- Common Patterns
- The Decision Tree
- Part 2: threshold Array
- What is a threshold Array?
- Single threshold vs Array
- How It Works
- Example 1: Fade In at Different Stages
- Example 2: Progress Bar Animation
- Example 3: Reading Progress Tracker
- Creating threshold Arrays
- When to Use threshold Arrays
- Performance Considerations
- Part 3: intersectionRatio
- What is intersectionRatio?
- Values Explained
- Relationship with threshold
- Example 1: Dynamic Opacity Based on Visibility
- Example 2: Scale Element Based on Visibility
- Example 3: Blur Effect Based on Visibility
- Example 4: Progress Indicator
- Example 5: Video Playback Control
- Example 6: Ad Visibility Tracking (IAB Standard)
- Converting intersectionRatio to Useful Values
- intersectionRatio vs isIntersecting
- 🎯 Putting It All Together
- Complete Example: Sophisticated Image Lazy Loader
- 📝 Quick Reference
- rootMargin
- threshold
- intersectionRatio
- 🔗 MDN Resources
🎯 What is Intersection Observer API?
The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.
In simple terms: It lets you know when an element enters or exits the viewport (or another container) without constantly checking scroll position.
Why Was It Created?
Historically, detecting visibility of an element has been a difficult task for which solutions have been unreliable and prone to causing the browser and the sites the user is accessing to become sluggish.
The old way (❌ Bad):
// Don't do this!
window.addEventListener('scroll', () => {
const rect = element.getBoundingClientRect()
if (rect.top >= 0 && rect.bottom <= window.innerHeight) {
// Element is visible
}
})
// Problems: Runs constantly, causes performance issues
The new way (✅ Good):
// Much better!
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Element is visible
}
})
})
observer.observe(element)
// Benefits: Efficient, asynchronous, optimized by browser
📚 Core Concepts (From MDN)
1. Root Element
The specified element is called the root element or root for the purposes of the Intersection Observer API.
root: null→ Uses the viewport (most common)root: document.querySelector('.container')→ Uses a specific container
2. Intersection Ratio
The degree of intersection between the target element and its root is the intersection ratio.
- 0.0 = Not visible at all
- 0.5 = 50% visible
- 1.0 = 100% visible
3. Thresholds
A list of thresholds, sorted in increasing numeric order, where each threshold is a ratio of intersection area to bounding box area of an observed target.
{
threshold: 0.5 // Callback fires when 50% visible
}
{
threshold: [0, 0.25, 0.5, 0.75, 1.0] // Fires at each 25%
}
🔧 Complete Syntax
Constructor
The IntersectionObserver() constructor creates and returns a new IntersectionObserver object.
const observer = new IntersectionObserver(callback, options)
Callback Function
const callback = (entries, observer) => {
entries.forEach(entry => {
// entry properties:
entry.isIntersecting // Boolean: is element visible?
entry.intersectionRatio // Number: 0.0 to 1.0
entry.target // The observed element
entry.boundingClientRect // Element's position
entry.intersectionRect // Visible portion
entry.rootBounds // Root container bounds
entry.time // Timestamp
})
}
Options Object
An optional object which customizes the observer. You can provide any combination (or none) of the following options.
const options = {
root: null, // Element or null (viewport)
rootMargin: '0px', // Margin around root
threshold: 0.5, // When to trigger (0.0 - 1.0)
// Advanced options:
scrollMargin: '0px', // Margin for nested scrollers
trackVisibility: true, // Track actual visibility
delay: 100 // Minimum delay between notifications (ms)
}
Methods
The IntersectionObserver interface provides methods to observe and stop observing target elements.
observer.observe(element) // Start watching an element
observer.unobserve(element) // Stop watching an element
observer.disconnect() // Stop watching all elements
observer.takeRecords() // Get pending entries
🎯 Real-World Use Cases (From MDN)
1. Lazy Loading Images ⭐ Most Common
Lazy-loading of images or other content as a page is scrolled.
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src // Load the actual image
img.classList.remove('lazy')
observer.unobserve(img) // Stop watching once loaded
}
})
}, {
rootMargin: '50px' // Start loading 50px before visible
})
// Observe all lazy images
document.querySelectorAll('img.lazy').forEach(img => {
imageObserver.observe(img)
})
HTML:
<img class="lazy" data-src="high-res.jpg" src="placeholder.jpg" alt="Description">
2. Infinite Scroll
Implementing "infinite scrolling" websites, where more and more content is loaded and rendered as you scroll, so that the user doesn't have to flip through pages.
const sentinelObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadMoreContent()
}
})
}, {
rootMargin: '100px' // Trigger 100px before reaching bottom
})
// Observe a sentinel element at the bottom
const sentinel = document.querySelector('.scroll-sentinel')
sentinelObserver.observe(sentinel)
function loadMoreContent() {
// Fetch and append new content
fetch('/api/posts?page=' + currentPage)
.then(res => res.json())
.then(data => {
appendPosts(data)
currentPage++
})
}
3. Ad Visibility Tracking
Reporting of visibility of advertisements in order to calculate ad revenues.
There's a good reason why the notion of tracking visibility of ads is being used in this example. It turns out that one of the most common uses of Flash or other script in advertising on the Web is to record how long each ad is visible, for the purpose of billing and payment of revenues.
const adObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const ad = entry.target
if (entry.isIntersecting) {
// Ad became visible
ad.dataset.visibleSince = Date.now()
} else {
// Ad is no longer visible
if (ad.dataset.visibleSince) {
const visibleTime = Date.now() - parseInt(ad.dataset.visibleSince)
trackAdVisibility(ad.id, visibleTime)
delete ad.dataset.visibleSince
}
}
})
}, {
threshold: 0.5 // Consider "visible" when 50% shown
})
document.querySelectorAll('.ad').forEach(ad => {
adObserver.observe(ad)
})
4. Scroll Animations
const animateObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in')
}
})
}, {
threshold: 0.2 // Trigger when 20% visible
})
document.querySelectorAll('.animate-on-scroll').forEach(el => {
animateObserver.observe(el)
})
CSS:
.animate-on-scroll {
opacity: 0;
transform: translateY(50px);
transition: opacity 0.6s, transform 0.6s;
}
.animate-on-scroll.animate-in {
opacity: 1;
transform: translateY(0);
}
5. Active Navigation Highlighting
const sectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const id = entry.target.id
const navLink = document.querySelector(`a[href="#${id}"]`)
if (entry.isIntersecting) {
navLink.classList.add('active')
} else {
navLink.classList.remove('active')
}
})
}, {
threshold: 0.5,
rootMargin: '-100px 0px -66%' // Only top section is active
})
document.querySelectorAll('section[id]').forEach(section => {
sectionObserver.observe(section)
})
6. Video Auto-Play/Pause
const videoObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const video = entry.target
if (entry.isIntersecting) {
video.play()
} else {
video.pause()
}
})
}, {
threshold: 0.5 // Play when 50% visible
})
document.querySelectorAll('video.auto-play').forEach(video => {
videoObserver.observe(video)
})
7. Analytics - Track What Users See
const viewTracker = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// User saw this content
analytics.track('content_viewed', {
element: entry.target.id,
visibleRatio: entry.intersectionRatio
})
// Only track once
viewTracker.unobserve(entry.target)
}
})
}, {
threshold: 0.8 // 80% visible = "viewed"
})
document.querySelectorAll('.track-view').forEach(el => {
viewTracker.observe(el)
})
💡 Best Practices
1. Always Unobserve When Done
// ✅ Good - Stop observing after action
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadImage(entry.target)
obs.unobserve(entry.target) // ← Important!
}
})
})
// ❌ Bad - Keeps observing unnecessarily
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadImage(entry.target)
// Still being observed = wasted resources
}
})
})
2. Clean Up in React/Vue Components
// React example
useEffect(() => {
const observer = new IntersectionObserver(callback, options)
const element = ref.current
if (element) {
observer.observe(element)
}
// ✅ Cleanup function
return () => {
if (element) {
observer.unobserve(element)
}
}
}, [])
3. Use rootMargin for Preloading
// ✅ Good - Start loading before visible
const observer = new IntersectionObserver(callback, {
rootMargin: '200px' // Trigger 200px before entering viewport
})
// ❌ Less optimal - Wait until visible
const observer = new IntersectionObserver(callback, {
rootMargin: '0px' // User might see loading
})
4. Handle Multiple Thresholds Efficiently
Either a single number or an array of numbers between 0.0 and 1.0, specifying a ratio of intersection area to total bounding box area for the observed target.
// ✅ Good - Multiple thresholds for precise control
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.intersectionRatio > 0.9) {
element.classList.add('fully-visible')
} else if (entry.intersectionRatio > 0.5) {
element.classList.add('mostly-visible')
} else if (entry.intersectionRatio > 0.1) {
element.classList.add('partially-visible')
}
})
}, {
threshold: [0, 0.1, 0.5, 0.9, 1.0]
})
5. Consider Page Visibility API for Ads
The Intersection Observer API doesn't take this into account when detecting intersection, since intersection isn't affected by page visibility. Therefore, we need to pause our timers while the page is tabbed out.
// Handle tab switching for accurate ad tracking
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// Pause all ad timers
pauseAdTimers()
} else {
// Resume ad timers
resumeAdTimers()
}
})
6. Use trackVisibility for Real Visibility
When tracking visibility, the browser will check that the target does not have compromised visibility when calculating intersections; for example, that it hasn't been covered by another element, have reduced opacity, or be distorted by a filter, transform, or other modification.
// For ads or critical content
const observer = new IntersectionObserver(callback, {
trackVisibility: true,
delay: 100 // Required when trackVisibility is true
})
7. Reuse Observers
// ✅ Good - One observer for many elements
const imageObserver = new IntersectionObserver(callback, options)
document.querySelectorAll('img.lazy').forEach(img => {
imageObserver.observe(img)
})
// ❌ Bad - New observer for each element
document.querySelectorAll('img.lazy').forEach(img => {
const observer = new IntersectionObserver(callback, options)
observer.observe(img)
})
8. Check isIntersecting First
The observer callback will always fire the first render cycle after observe() is called, even if the observed element has not yet moved with respect to the viewport.
// ✅ Good - Check isIntersecting
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Only act when entering
doSomething()
}
})
})
// ⚠️ Be aware - fires immediately on observe()
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// This runs even before scrolling!
doSomething()
})
})
9. Handle Errors Gracefully
// Check browser support
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(callback, options)
observer.observe(element)
} else {
// Fallback: load everything immediately
loadAllImages()
}
10. Optimize Threshold Arrays
// ❌ Bad - Too many thresholds
const observer = new IntersectionObserver(callback, {
threshold: Array.from({ length: 100 }, (_, i) => i / 100)
})
// ✅ Good - Only what you need
const observer = new IntersectionObserver(callback, {
threshold: [0, 0.25, 0.5, 0.75, 1.0]
})
🚀 Advanced Patterns
Lazy Load with Blur Effect
const lazyObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
const fullImg = new Image()
fullImg.onload = () => {
img.src = fullImg.src
img.classList.add('loaded')
}
fullImg.src = img.dataset.src
observer.unobserve(img)
}
})
}, { rootMargin: '50px' })
CSS:
img.lazy {
filter: blur(10px);
transition: filter 0.3s;
}
img.lazy.loaded {
filter: blur(0);
}
Pause Expensive Animations
const animationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const animation = entry.target.animation
if (entry.isIntersecting) {
animation.play()
} else {
animation.pause()
}
})
}, { threshold: 0.5 })
Progressive Image Loading
const progressiveObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
// Load low quality first
img.src = img.dataset.lowres
// Then load high quality
const highResImg = new Image()
highResImg.onload = () => {
img.src = highResImg.src
}
highResImg.src = img.dataset.highres
observer.unobserve(img)
}
})
})
📊 Performance Comparison
Old Scroll Listener vs Intersection Observer
// ❌ OLD WAY - Runs constantly
let ticking = false
window.addEventListener('scroll', () => {
if (!ticking) {
window.requestAnimationFrame(() => {
checkVisibility() // Expensive!
ticking = false
})
ticking = true
}
})
// ✅ NEW WAY - Runs only when needed
const observer = new IntersectionObserver(callback)
observer.observe(element)
// Performance difference:
// Scroll listener: 60-100 calls/second
// Intersection Observer: 1-2 calls per visibility change
🌐 Browser Support
Excellent support (97%+ as of 2025)
- ✅ Chrome 51+
- ✅ Firefox 55+
- ✅ Safari 12.1+
- ✅ Edge 15+
Polyfill: intersection-observer for older browsers
npm install intersection-observer
📖 Summary
The Intersection Observer API is:
- Performant: Optimized by browser, doesn't block main thread
- Asynchronous: Runs in background
- Versatile: Works for lazy loading, infinite scroll, animations, analytics
- Easy to use: Simple API, powerful results
- Well-supported: Available in all modern browsers
Use it for:
- ✅ Lazy loading images/videos
- ✅ Infinite scroll
- ✅ Scroll animations
- ✅ Ad visibility tracking
- ✅ Analytics
- ✅ Auto-play/pause videos
- ✅ Active navigation highlighting
Avoid using it for:
- ❌ Precise pixel measurements (use getBoundingClientRect)
- ❌ Continuous scroll position tracking (use scroll events with throttling)
- ❌ Drag and drop (use Pointer Events)
Advanced Concept: rootMargin, threshold & intersectionRatio
Part 1: rootMargin and threshold Together
Understanding Each Separately
rootMargin (When to check)
- Grows or shrinks the root's bounding box before calculating intersection
- Works exactly like CSS margin:
"top right bottom left" - Positive values: Triggers BEFORE element enters viewport
- Negative values: Triggers AFTER element enters viewport
threshold (How much to see)
- A number between
0.0and1.0(or array of numbers) - Specifies what percentage of the element must be visible to trigger
0= any pixel visible0.5= 50% visible1.0= 100% visible
Why Use Them Together?
rootMargin controls WHEN to start checking, threshold controls HOW MUCH needs to be visible.
Example 1: Lazy Load Images Early
Goal: Start loading images 200px BEFORE they enter viewport, but only trigger when 50% visible.
const imageObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// This fires when:
// 1. Element is within 200px of viewport (rootMargin)
// 2. AND 50% of element is visible (threshold)
loadImage(entry.target)
}
})
},
{
rootMargin: '200px', // Expand viewport by 200px
threshold: 0.5 // Need 50% visible
}
)
Visual Explanation:
Without rootMargin:
┌─────────────────┐
│ Viewport │ ← Normal viewport boundary
│ │
│ [Image] │ ← Image enters here (0% visible)
│ │
└─────────────────┘
With rootMargin: '200px':
┌──────────┐
│ │ ← Extended boundary (200px above viewport)
│ │
┌───┼──────────┼───┐
│ │ Viewport │ │ ← Actual viewport
│ │ │ │
│ │ [Image] │ │ ← Image triggers here (200px before entering)
│ │ │ │
└───┼──────────┼───┘
│ │
└──────────┘ ← Extended boundary (200px below viewport)
Example 2: Trigger After Element is Fully In View
Goal: Only trigger when element is FULLY inside viewport AND 100% visible.
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Element is completely inside viewport
animateElement(entry.target)
}
})
},
{
rootMargin: '-50px', // Shrink viewport by 50px
threshold: 1.0 // Need 100% visible
}
)
Visual Explanation:
Without rootMargin (element at edge triggers):
┌─────────────────┐
│ Viewport │
│ [Element] │ ← Triggers here (partially visible)
│ │
└─────────────────┘
With rootMargin: '-50px':
┌─────────────────┐
│ │
│ ┌──────────┐ │
│ │ Shrunken │ │ ← Element must be fully inside this
│ │ Viewport │ │
│ │ │ │
│ │ [Element]│ │ ← Triggers here (fully inside)
│ │ │ │
│ └──────────┘ │
│ │
└─────────────────┘
Example 3: Different Margins for Different Sides
const observer = new IntersectionObserver(
callback,
{
// "top right bottom left" (like CSS)
rootMargin: '100px 0px 200px 0px',
// 100px above viewport
// 0px on right
// 200px below viewport
// 0px on left
threshold: 0.5
}
)
Common Patterns
Pattern 1: Preload Content (Images, Ads, Videos)
{
rootMargin: '200px', // Load before visible
threshold: 0 // Start as soon as any part enters
}
Pattern 2: Ad Visibility Tracking (50% rule)
{
rootMargin: '0px', // Use actual viewport
threshold: 0.5 // Count as "viewed" at 50%
}
Pattern 3: Scroll Animations (Wait until well inside)
{
rootMargin: '-100px', // Wait until inside viewport
threshold: 0.3 // Trigger at 30% visible
}
Pattern 4: Infinite Scroll (Load before bottom)
{
rootMargin: '0px 0px 400px 0px', // 400px before bottom
threshold: 0 // Any visibility
}
The Decision Tree
How to choose values?
1. When to start checking?
└─ Before visible → rootMargin: positive ('200px')
└─ When visible → rootMargin: '0px'
└─ After visible → rootMargin: negative ('-50px')
2. How much needs to be visible?
└─ Any pixel → threshold: 0
└─ Half visible → threshold: 0.5
└─ Fully visible → threshold: 1.0
Part 2: threshold Array
What is a threshold Array?
Instead of a single number, you can provide an array of thresholds. The callback fires every time the element crosses one of these percentages.
Single threshold vs Array
Single threshold:
{
threshold: 0.5 // Fires once when crossing 50%
}
Array of thresholds:
{
threshold: [0, 0.25, 0.5, 0.75, 1.0]
// Fires 5 times:
// - When any pixel becomes visible (0)
// - When 25% becomes visible
// - When 50% becomes visible
// - When 75% becomes visible
// - When 100% becomes visible
}
How It Works
As you scroll, the callback triggers every time you cross a threshold:
Element scrolling into view:
┌────────────┐
│ Viewport │
│ │ [Element] ← 0% visible
│ │ ↓
│ │ [Elem... ← 25% visible → TRIGGER! 🔔
│ │ ↓
│ │ [Elemen ← 50% visible → TRIGGER! 🔔
│ [Element ↓
│ │ [Element] ← 75% visible → TRIGGER! 🔔
│ [Element] ↓
│ │ ← 100% visible → TRIGGER! 🔔
└────────────┘
Example 1: Fade In at Different Stages
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const ratio = entry.intersectionRatio
const element = entry.target
if (ratio >= 0.75) {
element.style.opacity = '1'
element.classList.add('fully-visible')
} else if (ratio >= 0.5) {
element.style.opacity = '0.7'
element.classList.add('mostly-visible')
} else if (ratio >= 0.25) {
element.style.opacity = '0.4'
element.classList.add('partially-visible')
} else if (ratio > 0) {
element.style.opacity = '0.2'
element.classList.add('barely-visible')
}
})
},
{
threshold: [0, 0.25, 0.5, 0.75, 1.0]
}
)
Example 2: Progress Bar Animation
const progressObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const percentage = Math.round(entry.intersectionRatio * 100)
// Update progress bar
progressBar.style.width = `${percentage}%`
progressBar.textContent = `${percentage}% visible`
// Log each threshold crossing
console.log(`Element is ${percentage}% visible`)
})
},
{
// Create 101 thresholds (0, 0.01, 0.02, ... 0.99, 1.0)
threshold: Array.from({ length: 101 }, (_, i) => i / 100)
}
)
Example 3: Reading Progress Tracker
const articleObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.intersectionRatio >= 0.9) {
// User read 90% of article
trackEvent('article_almost_complete')
} else if (entry.intersectionRatio >= 0.5) {
// User read half
trackEvent('article_half_read')
} else if (entry.intersectionRatio >= 0.25) {
// User started reading
trackEvent('article_started')
}
})
},
{
threshold: [0.25, 0.5, 0.9]
}
)
observer.observe(articleElement)
Creating threshold Arrays
Method 1: Manual Array
{
threshold: [0, 0.25, 0.5, 0.75, 1.0]
}
Method 2: Generate with Array.from()
{
// Every 10%: [0, 0.1, 0.2, ... 0.9, 1.0]
threshold: Array.from({ length: 11 }, (_, i) => i / 10)
}
Method 3: Very Smooth (Every 1%)
{
// Every 1%: [0, 0.01, 0.02, ... 0.99, 1.0]
threshold: Array.from({ length: 101 }, (_, i) => i / 100)
}
When to Use threshold Arrays
✅ Use threshold array when:
- You need different behavior at different visibility levels
- Creating smooth animations based on scroll position
- Tracking reading progress
- Building scroll-based progress indicators
- Need fine-grained control over visibility states
❌ Don't use threshold array when:
- You only care about one specific visibility point
- Performance is critical (more thresholds = more callback fires)
- Simple show/hide is enough
Performance Considerations
// ❌ BAD - 1000 thresholds = 1000 possible callback fires
{
threshold: Array.from({ length: 1001 }, (_, i) => i / 1000)
}
// ✅ GOOD - 11 thresholds is usually enough
{
threshold: Array.from({ length: 11 }, (_, i) => i / 10)
}
// ✅ BEST - Only what you need
{
threshold: [0, 0.5, 1.0] // Just 3 states
}
Part 3: intersectionRatio
What is intersectionRatio?
intersectionRatio is a property on the entry object that tells you exactly how much of the element is currently visible.
- Type: Number between
0.0and1.0 - Read-only: You can't set it, only read it
- Real-time: Updates as element scrolls
Values Explained
entry.intersectionRatio === 0.0 // Element is 0% visible (completely hidden)
entry.intersectionRatio === 0.25 // Element is 25% visible
entry.intersectionRatio === 0.5 // Element is 50% visible
entry.intersectionRatio === 0.75 // Element is 75% visible
entry.intersectionRatio === 1.0 // Element is 100% visible (fully shown)
Relationship with threshold
threshold = what triggers the callback intersectionRatio = what's actually visible
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
console.log('Threshold:', 0.5) // What we set
console.log('Actual ratio:', entry.intersectionRatio) // What's visible
// These might be different!
// threshold = 0.5 means "fire callback when crossing 50%"
// intersectionRatio could be 0.51, 0.6, 0.75, etc.
})
},
{ threshold: 0.5 }
)
Example 1: Dynamic Opacity Based on Visibility
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const element = entry.target
const ratio = entry.intersectionRatio
// Element opacity matches how visible it is
element.style.opacity = ratio
// 0% visible → opacity: 0 (invisible)
// 50% visible → opacity: 0.5 (half transparent)
// 100% visible → opacity: 1 (fully opaque)
})
},
{
threshold: Array.from({ length: 101 }, (_, i) => i / 100)
}
)
Example 2: Scale Element Based on Visibility
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const ratio = entry.intersectionRatio
// Scale from 0.5 to 1.0 based on visibility
const scale = 0.5 + (ratio * 0.5)
entry.target.style.transform = `scale(${scale})`
// 0% visible → scale(0.5)
// 50% visible → scale(0.75)
// 100% visible → scale(1.0)
})
},
{
threshold: Array.from({ length: 21 }, (_, i) => i / 20)
}
)
Example 3: Blur Effect Based on Visibility
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const ratio = entry.intersectionRatio
// Blur reduces as element becomes more visible
const blur = 20 - (ratio * 20)
entry.target.style.filter = `blur(${blur}px)`
// 0% visible → blur(20px) - very blurry
// 50% visible → blur(10px) - medium blur
// 100% visible → blur(0px) - sharp
})
},
{
threshold: Array.from({ length: 21 }, (_, i) => i / 20)
}
)
Example 4: Progress Indicator
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const ratio = entry.intersectionRatio
const percentage = Math.round(ratio * 100)
// Update progress bar
progressBar.style.width = `${percentage}%`
progressText.textContent = `Reading: ${percentage}%`
// Update color based on progress
if (percentage >= 75) {
progressBar.style.backgroundColor = '#10b981' // green
} else if (percentage >= 50) {
progressBar.style.backgroundColor = '#f59e0b' // orange
} else if (percentage >= 25) {
progressBar.style.backgroundColor = '#3b82f6' // blue
} else {
progressBar.style.backgroundColor = '#64748b' // gray
}
})
},
{
threshold: Array.from({ length: 101 }, (_, i) => i / 100)
}
)
Example 5: Video Playback Control
const videoObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const video = entry.target
const ratio = entry.intersectionRatio
if (ratio >= 0.5) {
// More than 50% visible → play
video.play()
} else {
// Less than 50% visible → pause
video.pause()
}
// Also adjust volume based on visibility
video.volume = ratio
// 100% visible → volume: 1.0 (full)
// 50% visible → volume: 0.5 (half)
// 0% visible → volume: 0.0 (muted)
})
},
{
threshold: [0, 0.5, 1.0]
}
)
Example 6: Ad Visibility Tracking (IAB Standard)
The Interactive Advertising Bureau (IAB) defines a viewable impression as:
- At least 50% of ad pixels visible
- For at least 1 continuous second
const adTimers = new Map()
const adObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const ad = entry.target
const ratio = entry.intersectionRatio
if (ratio >= 0.5) {
// Ad is 50%+ visible
if (!adTimers.has(ad)) {
// Start timer
const timerId = setTimeout(() => {
// After 1 second, count as viewable impression
trackViewableImpression(ad.id)
}, 1000)
adTimers.set(ad, timerId)
}
} else {
// Ad dropped below 50%
if (adTimers.has(ad)) {
// Clear timer (didn't meet 1 second requirement)
clearTimeout(adTimers.get(ad))
adTimers.delete(ad)
}
}
})
},
{
threshold: [0.5] // IAB standard
}
)
Converting intersectionRatio to Useful Values
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const ratio = entry.intersectionRatio
// As percentage
const percentage = ratio * 100 // 0.75 → 75
// As pixels (approximate)
const height = entry.boundingClientRect.height
const visiblePixels = height * ratio
// As degrees (for rotation)
const degrees = ratio * 360 // 0.5 → 180deg
// As color intensity
const alpha = ratio // For rgba(255, 0, 0, alpha)
console.log({
percentage: `${percentage.toFixed(0)}%`,
pixels: `${visiblePixels.toFixed(0)}px`,
degrees: `${degrees.toFixed(0)}deg`,
alpha: alpha.toFixed(2)
})
})
})
intersectionRatio vs isIntersecting
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// isIntersecting: Boolean (true/false)
console.log('Is intersecting?', entry.isIntersecting)
// true if ratio > 0 and passes threshold
// intersectionRatio: Number (0.0 to 1.0)
console.log('How much?', entry.intersectionRatio)
// Exact amount visible
})
})
// When to use which?
// Use isIntersecting when:
if (entry.isIntersecting) {
// Simple yes/no check
loadImage()
}
// Use intersectionRatio when:
const opacity = entry.intersectionRatio
element.style.opacity = opacity
// Need exact visibility amount
🎯 Putting It All Together
Complete Example: Sophisticated Image Lazy Loader
const imageObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const img = entry.target
const ratio = entry.intersectionRatio
// Load when any part enters (with early margin)
if (entry.isIntersecting && !img.dataset.loaded) {
// Start loading
const fullImg = new Image()
fullImg.onload = () => {
img.src = fullImg.src
img.dataset.loaded = 'true'
}
fullImg.src = img.dataset.src
}
// Fade in based on visibility
img.style.opacity = ratio
// Add blur effect
img.style.filter = `blur(${(1 - ratio) * 10}px)`
// Scale slightly
img.style.transform = `scale(${0.95 + ratio * 0.05})`
})
},
{
rootMargin: '100px', // Start loading 100px early
threshold: Array.from({ length: 21 }, (_, i) => i / 20) // Smooth animation
}
)
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img)
})
📝 Quick Reference
rootMargin
- Purpose: Adjust when to check (expand/shrink viewport)
- Format:
"10px 20px 30px 40px"(CSS margin syntax) - Positive: Triggers before entering viewport
- Negative: Triggers after entering viewport
threshold
- Purpose: How much must be visible to trigger
- Type: Number (0-1) or array of numbers
- Single:
0.5→ fires once at 50% - Array:
[0, 0.5, 1]→ fires at 0%, 50%, 100%
intersectionRatio
- Purpose: Exact visibility amount
- Type: Number (0.0 to 1.0)
- Read-only: Can't be set, only read
- Use for: Dynamic effects, progress tracking