Knowing precisely when an element enters or leaves the viewport — and by how much — is fundamental to lazy loading, scroll-driven animation, and advertising measurement. Legacy scroll listeners solved this by polling on every scroll event, paying a synchronous layout cost on the main thread for every pixel of movement. The IntersectionObserver API eliminates that cost by delegating intersection math to the browser and delivering batched callbacks off the critical path.
Concept Framing
Dynamic visibility tracking sits at the practical heart of Implementation Patterns for Viewport & Resize Tracking. Where the parent section covers the full landscape of observer-based patterns, this page focuses on a specific challenge: maintaining accurate, up-to-date visibility state for one or many elements across the full lifecycle of a page — including SPA route transitions, conditional rendering, and virtual lists that add and remove DOM nodes continuously.
The diagram below shows how IntersectionObserver callbacks slot into the browser rendering pipeline relative to scroll events and requestAnimationFrame.
The observer callback arrives after layout is computed and before compositing, giving you stable geometry data without triggering a synchronous reflow.
Spec / Signature Reference Table
The table below covers the constructor options and entry properties most relevant to visibility tracking.
| Property / Option | Type | Default | Description |
|---|---|---|---|
root |
Element | null |
null (viewport) |
Scroll container used as the intersection root. |
rootMargin |
string |
"0px" |
CSS-margin-style expansion of the root bounds. Use to pre-load before entry ("200px 0px"). |
threshold |
number | number[] |
0 |
Fraction(s) of the target's area that must be visible to trigger a callback. 0 = any pixel; 1.0 = fully visible. |
entry.isIntersecting |
boolean |
— | true when the element crosses a threshold entering the root; false on exit. |
entry.intersectionRatio |
number |
— | The visible fraction at the moment of the callback, from 0.0 to 1.0. |
entry.intersectionRect |
DOMRectReadOnly |
— | The actual intersection rectangle in viewport coordinates. |
entry.boundingClientRect |
DOMRectReadOnly |
— | The full bounding box of the target; pre-calculated, no forced reflow. |
entry.rootBounds |
DOMRectReadOnly | null |
— | The root rectangle. null for cross-origin iframes. |
entry.target |
Element |
— | The observed element that triggered this entry. |
entry.time |
DOMHighResTimeStamp |
— | Milliseconds from the performance origin when the intersection changed. |
Step-by-Step Implementation
Step 1 — Feature-detect and configure
Always guard the constructor call so the page degrades gracefully in environments where the API is absent (server-side rendering, very old browsers).
// Step 1: Feature detection and base config
if (!('IntersectionObserver' in window)) {
// Graceful fallback: mark all tracked elements as visible
document.querySelectorAll('[data-observe]').forEach((el) => {
el.setAttribute('data-visible', 'true');
});
} else {
startVisibilityTracking();
}
// JS fallback note: replace the `if` block above identically; no TypeScript-specific syntax.
Step 2 — Define the visibility manager interface
export interface VisibilityConfig extends IntersectionObserverInit {
/** Fire callback only on the first intersection, then auto-unobserve. */
once?: boolean;
/** Called each time the element crosses any configured threshold. */
onVisibilityChange?: (entry: IntersectionObserverEntry) => void;
}
export interface VisibilityManager {
/** Start observing an element. Returns a teardown function. */
observe: (element: Element, config?: Partial<VisibilityConfig>) => () => void;
/** Stop all observation and release all references. */
disconnect: () => void;
/** Whether the manager is still active (false after disconnect). */
readonly isActive: boolean;
}
Step 3 — Implement the cleanup-aware manager
export function createVisibilityManager(
baseConfig: IntersectionObserverInit = {}
): VisibilityManager {
// One IntersectionObserver instance per observed element gives independent
// threshold configs. For identical configs, share an instance instead.
const observers = new Map<Element, IntersectionObserver>();
let active = true;
function observe(
element: Element,
config: Partial<VisibilityConfig> = {}
): () => void {
if (!active || !(element instanceof Element)) return () => {};
if (observers.has(element)) return () => {}; // no duplicate observers
const merged = { ...baseConfig, ...config };
const io = new IntersectionObserver((entries) => {
if (!active) return;
entries.forEach((entry) => {
config.onVisibilityChange?.(entry);
if (config.once && entry.isIntersecting) {
io.unobserve(entry.target);
observers.delete(entry.target);
}
});
}, merged);
observers.set(element, io);
io.observe(element);
// Return a per-element teardown closure for precise cleanup
return () => {
const obs = observers.get(element);
if (obs) {
obs.unobserve(element);
observers.delete(element);
}
};
}
function disconnect(): void {
active = false;
observers.forEach((io) => io.disconnect());
observers.clear();
}
return {
observe,
disconnect,
get isActive() { return active; },
};
}
/*
* Plain JS version — remove `: VisibilityConfig`, `: Element`, `: () => void`,
* and the `export interface` blocks. Runtime behaviour is identical.
*/
Step 4 — Batch state updates outside the callback
Observer callbacks run synchronously within the browser's rendering pipeline. Writing directly to the DOM or a reactive store inside the callback can cause the framework to schedule a re-render mid-frame. Use queueMicrotask or requestAnimationFrame to defer:
const pendingUpdates = new Map<Element, IntersectionObserverEntry>();
let rafScheduled = false;
function flushUpdates(): void {
pendingUpdates.forEach((entry, el) => {
// Safe to write DOM or update store here — runs at frame boundary
el.setAttribute('data-visible', String(entry.isIntersecting));
el.setAttribute('data-ratio', entry.intersectionRatio.toFixed(2));
});
pendingUpdates.clear();
rafScheduled = false;
}
const manager = createVisibilityManager({ threshold: [0, 0.5, 1.0] });
manager.observe(myElement, {
onVisibilityChange: (entry) => {
pendingUpdates.set(entry.target, entry);
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(flushUpdates);
}
},
});
Threshold / Configuration Variants
| Use Case | threshold |
rootMargin |
Behaviour |
|---|---|---|---|
| Lazy-load images | 0 or 0.1 |
"200px 0px" |
Fires 200 px before the element enters, giving time to fetch the asset. |
| Ad viewability (MRC standard) | 0.5 |
"0px" |
Reports when 50% of the ad is in view; fires again on exit. |
| Read-progress tracking | [0.25, 0.5, 0.75, 1.0] |
"0px" |
Fires at quarter-visibility milestones for analytics events. |
| Sticky-header offset | 0 |
"-64px 0px 0px" |
Shrinks the root by the header height so intersection reports "below the fold of the header". |
| One-shot animation trigger | 0.15 + once: true |
"0px" |
Triggers a CSS class once at 15% visibility, then stops tracking. |
| Full-viewport coverage check | 1.0 |
"0px" |
Fires only when every pixel of the element is visible — useful for media completion events. |
Edge Cases & Gotchas
display:none vs visibility:hidden
Elements with display:none have no layout box and can never cross a positive threshold — the observer never fires. Elements with visibility:hidden still occupy layout space and will trigger callbacks. Confusing the two breaks analytics pipelines that expect non-viewable impressions to never be counted.
CSS contain properties
contain:layout or contain:paint create a new stacking context that can prevent the browser from computing accurate intersection rectangles until the container is repainted. Avoid applying strong containment to observed ancestors without testing.
Hardware-accelerated layers
Elements promoted to their own compositor layer via will-change:transform or transform:translateZ(0) may report intersection differently from flow elements, particularly on mobile. Validate thresholds with transform-heavy designs before shipping.
Cross-origin iframes
Inside a cross-origin iframe, entry.rootBounds is null and all elements report 0% intersection — browsers block cross-origin geometry access for security. For ad slots in iframes, use same-origin proxy iframes or postMessage to relay visibility state from a parent-hosted observer.
Subpixel rounding
At fractional device pixel ratios (1.5×, 2.5×), intersectionRatio can read 0.9999 when you expect 1.0. Check ratio >= 0.99 rather than ratio === 1.0 when testing full visibility.
Observing before DOM attachment
Calling observe() on an element before it is attached to the document results in a silent no-op — the callback never fires for the initial state. Always defer observe() until after mount (useEffect, onMounted, connectedCallback). This is covered in detail under Observer Lifecycle & Memory Management.
Coalesced frames
Multiple threshold crossings within a single frame (rapid scroll) are delivered in a single callback as multiple IntersectionObserverEntry objects in the entries array. Always iterate the full array — do not assume entries.length === 1.
Framework Integration Patterns
React — useVisibilityObserver hook
import { useEffect, useRef, useState, RefObject } from 'react';
interface UseVisibilityOptions extends IntersectionObserverInit {
once?: boolean;
}
export function useVisibilityObserver<T extends Element>(
options: UseVisibilityOptions = {}
): [RefObject<T>, boolean, number] {
const ref = useRef<T>(null);
const [isVisible, setVisible] = useState(false);
const [ratio, setRatio] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !('IntersectionObserver' in window)) return;
const { once, ...ioOptions } = options;
const io = new IntersectionObserver(([entry]) => {
setVisible(entry.isIntersecting);
setRatio(entry.intersectionRatio);
if (once && entry.isIntersecting) io.unobserve(el);
}, ioOptions);
io.observe(el);
return () => io.disconnect(); // Cleanup on unmount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options.threshold, options.rootMargin, options.once]);
return [ref, isVisible, ratio];
}
// Usage:
// const [ref, visible, ratio] = useVisibilityObserver<HTMLDivElement>({ threshold: 0.5 });
// <div ref={ref} className={visible ? 'in-view' : ''}>{ratio}</div>
Vue 3 — useIntersection composable
import { ref, onMounted, onUnmounted, Ref } from 'vue';
export function useIntersection(
target: Ref<Element | null>,
options: IntersectionObserverInit = {}
) {
const isVisible = ref(false);
const ratio = ref(0);
let observer: IntersectionObserver | null = null;
onMounted(() => {
if (!target.value || !('IntersectionObserver' in window)) return;
observer = new IntersectionObserver(([entry]) => {
isVisible.value = entry.isIntersecting;
ratio.value = entry.intersectionRatio;
}, options);
observer.observe(target.value);
});
onUnmounted(() => {
observer?.disconnect();
observer = null;
});
return { isVisible, ratio };
}
Angular — intersection directive
import {
Directive, ElementRef, EventEmitter, Input,
OnDestroy, OnInit, Output
} from '@angular/core';
@Directive({ selector: '[appVisible]', standalone: true })
export class VisibleDirective implements OnInit, OnDestroy {
@Input() threshold = 0.5;
@Output() visibilityChange = new EventEmitter<boolean>();
private observer?: IntersectionObserver;
constructor(private host: ElementRef<Element>) {}
ngOnInit(): void {
if (!('IntersectionObserver' in window)) return;
this.observer = new IntersectionObserver(
([entry]) => this.visibilityChange.emit(entry.isIntersecting),
{ threshold: this.threshold }
);
this.observer.observe(this.host.nativeElement);
}
ngOnDestroy(): void {
this.observer?.disconnect();
}
}
// Usage: <div appVisible (visibilityChange)="onVisible($event)">…</div>
Debugging Checklist
Work through this list when observer callbacks are missing, misfiring, or duplicating.
-
Confirm the element is in the DOM when
observe()is called. In DevTools Elements panel, verify the element is attached. Add aconsole.log(el.isConnected)immediately beforeobserve(). -
Check for un-disconnected observers after route transitions. In Chrome Memory, take a Heap Snapshot and filter by
IntersectionObserver. Any instances attached toDetached DOM Treenodes are leaking. -
Validate your threshold against actual pixel coverage. Open DevTools → Performance → record a scroll. In the "Timings" row, look for
IntersectionObservertasks. If they fire more frequently than expected, your threshold granularity is too high. -
Test with
transformapplied. Applytransform: translateY(100px)to the element in DevTools. Intersection is computed on the visual position for non-composited elements, which can differ from the layout position. -
Reproduce script for "callback never fires":
// Run in console on the live page
const el = document.querySelector('#your-target');
console.log('In DOM:', el?.isConnected);
const io = new IntersectionObserver((entries) => {
console.table(entries.map(e => ({
target: e.target.id,
isIntersecting: e.isIntersecting,
ratio: e.intersectionRatio.toFixed(3),
time: e.time.toFixed(1),
})));
}, { threshold: [0, 0.5, 1] });
if (el) io.observe(el);
// Scroll to trigger — watch the console
- Monitor main-thread blocking.
Add a
PerformanceObserverforlongtaskentries. If observer callbacks appear inside long tasks (>50 ms), you are doing too much work inside the callback itself — move heavy logic torequestAnimationFrameor a Web Worker.
FAQ
Why does my IntersectionObserver callback not fire on initial render?
The observer fires its initial callback only after the browser completes the first layout pass for the observed element. If you call observe() before the element is attached to the DOM, the callback silently never runs. Always defer observation until the element is mounted — useEffect in React, onMounted in Vue, connectedCallback in custom elements.
Can I reuse a single IntersectionObserver instance for many elements?
Yes. One observer instance accepts multiple observe() calls and delivers all intersecting entries in a single batched callback invocation. The entry.target property identifies which element changed state. Sharing an instance is more memory-efficient than creating one observer per element, but requires all observed elements to share the same root, rootMargin, and threshold configuration.
How do I track 50% visibility for ad compliance without misfiring?
Pass threshold: [0.5] to the constructor. The callback fires exactly twice per element per threshold — once when coverage crosses 50% upward and once when it drops below. You do not need manual debouncing at this granularity because the browser coalesces sub-frame changes. See Tracking Ad Visibility for Analytics Compliance for the full IAB/MRC implementation.
Does `display:none` prevent IntersectionObserver callbacks?
Yes. Elements with display:none have no layout box and are treated as zero-area — they never cross a positive threshold. visibility:hidden elements still occupy space and can trigger callbacks. This distinction is critical in analytics pipelines: toggling visibility to hide a banner instead of removing it from the layout can cause the element to still be counted as "potentially viewable".
How do I handle IntersectionObserver inside cross-origin iframes?
Cross-origin iframes report 0% intersection for all elements — the browser blocks cross-origin geometry access for security. entry.rootBounds will be null. Use same-origin proxy iframes or postMessage-based coordination to relay visibility state from a parent-hosted observer into the iframe context.
Related
- Tracking Ad Visibility for Analytics Compliance
- Infinite Scroll & Pagination
- Lazy Loading Images & Media
- Preventing Memory Leaks in Long-Running Observers
- How IntersectionObserver Threshold Works in Practice
↑ Back to Implementation Patterns for Viewport & Resize Tracking