Intersection Observer v2: Knowing What Is Actually Visible
The Intersection Observer API has become a standard tool for lazy-loading media, triggering sticky header states, firing analytics events, and other tasks that depend on an element entering or leaving the viewport. The basic, v1 form of the API works like this:
const onIntersection = (entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
console.log(entry);
}
}
};
const observer = new IntersectionObserver(onIntersection);
observer.observe(document.querySelector('#some-target'));
What v1 does not tell you, however, is whether the observed element is genuinely visible to the user. An element can intersect the viewport and still be hidden behind other content, or it can be visually altered by CSS properties like transform, opacity, or filter in a way that makes it imperceptible.
Why Plain Intersection Is Not Enough
For elements in the top-level document, you could guess at visibility by walking the DOM and using methods like DocumentOrShadowRoot.elementFromPoint(). But this approach fails entirely for elements living inside a third-party iframe, where you have no access to the parent page's DOM.
This gap is a security problem. A dishonest publisher can use CSS like iframe { opacity: 0; } to hide ad iframes placed over attractive content, tricking users into clicking ads they never saw. This is clickjacking, and the demo linked above shows it in action: with "trick mode" on, an invisible ad registers clicks from users who believe they are simply interacting with a video.

The v2 Visibility Guarantee
Intersection Observer v2 introduces a new boolean field, isVisible, on the IntersectionObserverEntry. When isVisible is true, the browser guarantees that the target is both fully unoccluded and free of any visual effects that would hide or alter its rendering. If isVisible is false, the browser cannot make that guarantee.
The spec deliberately permits false negatives. Checking the visibility of every pixel is expensive, so browsers rely on conservative approximations like bounding boxes and rectangles. A mild border-radius, for instance, might cause the browser to report isVisible as false even when the element is largely on screen. What the spec does not allow are false positives: isVisible is never true unless the element is completely visible and unmodified.
Configuring the New Observer
The IntersectionObserver constructor now has two additional options:
delay— the minimum time, in milliseconds, between notifications for a given target.trackVisibility— a boolean that signals the observer's intent to track visibility changes.
Enabling trackVisibility requires setting delay to at least 100. This throttles updates to no more than one per 100ms because visibility tracking is computationally costly and could otherwise degrade performance or drain batteries. You should set delay to the largest value your use case tolerates.
The visibility calculation itself is strict. Per the spec, a target is considered invisible if any of the following conditions apply:
- It has an effective transformation matrix that is not a 2D translation or a proportional 2D upscaling.
- It, or any element in its containing block chain, has an effective opacity below 1.0.
- It, or any element in its containing block chain, has any CSS filter applied.
- The implementation cannot guarantee that the target is completely free of occlusion by other page content.
This conservative approach means even a nearly imperceptible filter: grayscale(0.01%) or an opacity: 0.99 will cause the browser to declare the element invisible. The following samples show the new API in practice:

<!DOCTYPE html>
<!-- This is the ad running in the iframe -->
<button id="callToActionButton">Buy now!</button>
// This is code running in the iframe.
// The iframe must be visible for at least 800ms prior to an input event
// for the input event to be considered valid.
const minimumVisibleDuration = 800;
// Keep track of when the button transitioned to a visible state.
let visibleSince = 0;
const button = document.querySelector('#callToActionButton');
button.addEventListener('click', (event) => {
if ((visibleSince > 0) &&
(performance.now() - visibleSince >= minimumVisibleDuration)) {
trackAdClick();
} else {
rejectAdClick();
}
});
const observer = new IntersectionObserver((changes) => {
for (const change of changes) {
// ⚠️ Feature detection
if (typeof change.isVisible === 'undefined') {
// The browser doesn't support v2, fallback to v1 behavior.
change.isVisible = true;
}
if (change.isIntersecting && change.isVisible) {
visibleSince = change.time;
} else {
visibleSince = 0;
}
}
}, {
threshold: [1.0],
// 🆕 Track the actual visibility of the element
trackVisibility: true,
// 🆕 Set a minimum delay between notifications
delay: 100
}));
// Require that the entire iframe be visible.
observer.observe(document.querySelector('#ad'));
The lower half of the associated demo exercises the same clickjacking scenario with v2 enabled. Turning on "trick mode" there no longer yields illegitimate ad clicks; the observer's isVisible flag correctly reports the ad iframe as hidden or modified, and the click tracking logic ignores the interaction.
Further Reading
- The Intersection Observer Working Draft at the W3C.
- The Intersection Observer v2 entry on Chrome Platform Status.



