Knowing when an element is actually visible
Web apps often need to know whether a given DOM element is actually on screen. Lazy-loading images and determining whether an ad is viewable are two common cases. In the past, the standard approach was to listen to the scroll event and call getBoundingClientRect() on the element in question. That works, but it's slow; each getBoundingClientRect() call forces the browser to re-layout the entire page, which can introduce noticeable jank.
The problem gets even harder inside an iframe. The Single Origin Model prevents an iframe from accessing any data about the containing page, so a script inside an iframe cannot determine whether the iframe itself is visible on the user's screen. That matters for ads and social widgets, which are typically loaded into iframes.
IntersectionObserver was designed to solve exactly this class of problem. The API is supported in all modern browsers and lets you register a callback that fires when an observed element enters or exits the browser's viewport.
Creating an observer
The API surface is small. An observer is created with a callback and an optional options object:
const io = new IntersectionObserver(entries => {
console.log(entries);
}, {
/* Using default options. Details below */
});
// Start observing an element
io.observe(element);
// Stop observing an element
// io.unobserve(element);
// Disable entire IntersectionObserver
// io.disconnect();
With the default options, the callback fires when an element becomes partially visible and again when it fully leaves the viewport. To observe multiple elements, call observe() multiple times on the same IntersectionObserver instance; this is both possible and recommended.
The callback receives an entries array of IntersectionObserverEntry objects, one per observed element with updated intersection data:
đź”˝[IntersectionObserverEntry]
time: 3893.92
đź”˝rootBounds: ClientRect
bottom: 920
height: 1024
left: 0
right: 1024
top: 0
width: 920
đź”˝boundingClientRect: ClientRect
// ...
đź”˝intersectionRect: ClientRect
// ...
intersectionRatio: 0.54
đź”˝target: div#observee
// ...
Each entry exposes several rectangles computed with getBoundingClientRect(). rootBounds describes the root (the viewport by default), boundingClientRect describes the observed element, and intersectionRect is the intersection of the two, telling you exactly which part of the element is visible. The related intersectionRatio value expresses how much of the element can be seen. With those numbers you can implement efficient just-in-time loading of assets before they scroll into view.
Data delivery is asynchronous. Callbacks run on the main thread, but the spec encourages implementations to schedule them via requestIdleCallback(), so your code runs during idle time rather than competing with scroll handling. That low-priority scheduling is an intentional design choice.
Beyond the viewport
Scrolling containers inside a page are also supported. The options object accepts a root element, which replaces the viewport as the reference for intersection calculations. The constraint is that root must be an ancestor of every observed element.
Observer etiquette and fine-tuning
Observing every item in a long list is wasteful. For an infinite scroller, the standard pattern is to observe a single sentinel element placed just after the last item. When the sentinel enters view, the callback loads data, appends new items, and repositions the sentinel. Because the observer keeps tracking the same sentinel, no additional observe() calls are needed.
Getting notified more often
The default behavior answers one binary question: is the element in view or not? Some features need finer-grained information. The threshold option accepts an array of intersectionRatio values, and the callback fires every time the ratio crosses one of them. The default is [0], which explains the single in/out notification. Setting threshold to [0, 0.25, 0.5, 0.75, 1] notifies you each time another quarter of the element becomes visible:
Adjusting the intersection area
One additional option is available: rootMargin. It uses CSS-style syntax such as "10px 20px 30px 40px" to grow or shrink the root's intersection area by specifying top, right, bottom, and left margins. All options on the observer:
new IntersectionObserver(entries => {/* … */}, {
// The root to use for intersection.
// If not provided, use the top-level document's viewport.
root: null,
// Same as margin, can be 1, 2, 3 or 4 components, possibly negative lengths.
// If an explicit root element is specified, components may be percentages of the
// root element size. If no explicit root element is specified, using a
// percentage is an error.
rootMargin: "0px",
// Threshold(s) at which to trigger callback, specified as a ratio, or list of
// ratios, of (visible area / total area) of the observed element (hence all
// entries must be in the range [0, 1]). Callback will be invoked when the
// visible ratio of the observed element crosses a threshold in the list.
threshold: [0],
});
Iframe visibility
Iframes were a primary reason for designing this API. When an element inside an iframe is observed, scrolling the iframe or scrolling the parent window both fire the callback at the appropriate times. In the cross-origin case, rootBounds is set to null to avoid leaking data about the parent page.
What it is not for
IntersectionObserver is deliberately neither pixel-perfect nor low-latency. Scroll-driven animations that depend on precise, up-to-date positions will not work well because the delivered data is technically stale by the time the callback runs. The original use cases are documented in the WICG explainer.
The normal rules of main-thread performance apply: if the callback does too much work, the app will lag.
Support is solid across modern browsers, and a polyfill from WICG's repository covers older ones, with the caveat that it cannot provide the same performance as a native implementation.



