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.

IntersectionObserver in the Browser Rendering Pipeline A horizontal sequence diagram showing: user scroll input, scroll event (main thread, synchronous), JavaScript task queue, layout and style recalc, IntersectionObserver callback (off main-thread scheduling), requestAnimationFrame, and paint. Active observer callback path is highlighted in crimson. USER MAIN THREAD BROWSER PAINT scroll input scroll event 🔴 (sync, blocks) getBoundingClientRect() forces reflow ⚠ layout thrash 🔴 — legacy scroll-listener path — Layout & Style recalculation IO callback ✓ batched, no forced reflow requestAnimationFrame Composite & Paint — IntersectionObserver path (no main-thread block) — Screen update

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).

TypeScript
// 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

TypeScript
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

TypeScript
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:

TypeScript
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

TypeScript
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

TypeScript
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

TypeScript
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.

  1. Confirm the element is in the DOM when observe() is called. In DevTools Elements panel, verify the element is attached. Add a console.log(el.isConnected) immediately before observe().

  2. Check for un-disconnected observers after route transitions. In Chrome Memory, take a Heap Snapshot and filter by IntersectionObserver. Any instances attached to Detached DOM Tree nodes are leaking.

  3. Validate your threshold against actual pixel coverage. Open DevTools → Performance → record a scroll. In the "Timings" row, look for IntersectionObserver tasks. If they fire more frequently than expected, your threshold granularity is too high.

  4. Test with transform applied. Apply transform: 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.

  5. Reproduce script for "callback never fires":

JavaScript
// 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
  1. Monitor main-thread blocking. Add a PerformanceObserver for longtask entries. If observer callbacks appear inside long tasks (>50 ms), you are doing too much work inside the callback itself — move heavy logic to requestAnimationFrame or 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.


↑ Back to Implementation Patterns for Viewport & Resize Tracking