Implementing Intersection Observer for Element Visibility Tracking on a Website
Intersection Observer — a browser API for asynchronously observing whether an element enters the viewport or another container. Works in a separate thread, doesn't block the main thread, doesn't cause layout thrashing — unlike the old approach with getBoundingClientRect() in a scroll handler.
Applications: lazy loading images, animations on appearance, infinite scroll, tracking read content, ad impressions.
Basic Setup
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// entry.isIntersecting — whether element is visible
// entry.intersectionRatio — fraction of visible part (0 to 1)
// entry.boundingClientRect — element size and position
// entry.time — timestamp
})
},
{
root: null, // null = viewport
rootMargin: '0px', // margins (like CSS margin)
threshold: 0.1, // trigger at 10% visibility
// threshold: [0, 0.25, 0.5, 0.75, 1] — multiple thresholds
}
)
observer.observe(element)
observer.unobserve(element)
observer.disconnect() // stop all observations
Animations on Appearance
Pattern without extra libraries:
function setupRevealAnimations(selector = '[data-reveal]'): () => void {
const elements = document.querySelectorAll<HTMLElement>(selector)
if (!elements.length) return () => {}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target as HTMLElement
el.classList.add('is-revealed')
observer.unobserve(el)
}
})
},
{ threshold: 0.15, rootMargin: '0px 0px -50px 0px' }
)
elements.forEach((el) => observer.observe(el))
return () => observer.disconnect()
}
CSS:
[data-reveal] {
opacity: 0;
transform: translateY(24px);
transition: opacity 500ms ease, transform 500ms ease;
}
[data-reveal].is-revealed {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
[data-reveal] { opacity: 1; transform: none; transition: none; }
}
Lazy Loading Images
function lazyLoadImages(selector = 'img[data-src]'): void {
const images = document.querySelectorAll<HTMLImageElement>(selector)
// Native lazy loading as primary method
if ('loading' in HTMLImageElement.prototype) {
images.forEach((img) => {
img.src = img.dataset.src!
img.removeAttribute('data-src')
})
return
}
// Intersection Observer as fallback
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
const img = entry.target as HTMLImageElement
img.src = img.dataset.src!
img.removeAttribute('data-src')
observer.unobserve(img)
})
},
{ rootMargin: '200px 0px' } // load 200px before appearance
)
images.forEach((img) => observer.observe(img))
}
Infinite Scroll
function createInfiniteScroll(
sentinel: HTMLElement,
onLoadMore: () => Promise<boolean> // returns false when data ends
): () => void {
let loading = false
const observer = new IntersectionObserver(
async (entries) => {
const entry = entries[0]
if (!entry.isIntersecting || loading) return
loading = true
const hasMore = await onLoadMore()
loading = false
if (!hasMore) observer.disconnect()
},
{ rootMargin: '400px 0px' }
)
observer.observe(sentinel)
return () => observer.disconnect()
}
Tracking Read Depth (Analytics)
function trackReadDepth(
article: HTMLElement,
onMilestone: (percent: number) => void
): () => void {
const thresholds = [0.25, 0.5, 0.75, 1.0]
const reported = new Set<number>()
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const ratio = entry.intersectionRatio
for (const t of thresholds) {
if (ratio >= t && !reported.has(t)) {
reported.add(t)
onMilestone(t * 100)
}
}
})
},
{ threshold: thresholds }
)
observer.observe(article)
return () => observer.disconnect()
}
// Usage:
trackReadDepth(articleEl, (percent) => {
analytics.track('article_read_depth', { percent })
})
React Hook
function useIntersectionObserver(
options: IntersectionObserverInit = {}
): [RefObject<HTMLElement | null>, boolean, IntersectionObserverEntry | null] {
const ref = useRef<HTMLElement>(null)
const [isVisible, setIsVisible] = useState(false)
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
setIsVisible(entry.isIntersecting)
setEntry(entry)
},
options
)
observer.observe(el)
return () => observer.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options.threshold, options.rootMargin])
return [ref, isVisible, entry]
}
// Usage:
function AnimatedCard() {
const [ref, isVisible] = useIntersectionObserver({ threshold: 0.2 })
return (
<div
ref={ref as React.RefObject<HTMLDivElement>}
className={isVisible ? 'card card--visible' : 'card'}
>
...
</div>
)
}
What's Included
Setting up observers for needed scenarios — animations, lazy loading, infinite scroll, read analytics. React hooks, proper cleanup of observers on unmounting, prefers-reduced-motion for animations.
Timeline: 0.5 day for basic scenarios, 1 day with multiple patterns.







