IntersectionObserver clips each target by every ancestor that clips overflow — overflow: hidden, auto, scroll and clip — before intersecting it with the root, so content hidden by such a parent correctly reports as not intersecting; but it ignores clip-path, opacity, visibility and occlusion by other elements, which need different handling.
Problem / Scenario Context
A news site's "Top stories" module shows five headlines in a fixed-height box with overflow: hidden; a "More" button expands it. Analytics wants impressions only for headlines the user can actually see. The team assumes the observer will report all five as visible because they are "on screen" — and is surprised to find the hidden ones correctly reported as not intersecting. Meanwhile, a second module that hides overflow with clip-path: inset(0 0 60% 0) reports all of its items as fully visible, and a third that fades items with opacity: 0 counts invisible items too.
The observer's model of "visible" is geometric and specific. Knowing exactly what it clips — and what it does not — prevents both over- and under-counting. The IntersectionObserver API Deep Dive covers the algorithm in outline.
Mechanics Explanation
The compute the intersection step of the specification walks from the target up to the root. At each ancestor that has a content clip — any overflow value other than visible, which includes hidden, clip, auto and scroll — the running intersection rectangle is intersected with that ancestor's clip rectangle. CSS contain: paint also clips. The result is then intersected with the root rectangle.
What the algorithm does not consider:
clip-pathandmask. They affect painting, not the geometric clip used by the observer.opacityandvisibility: hidden. The element still occupies its box; it is geometrically present.- Occlusion. Another element painted on top does not reduce the intersection. Only IntersectionObserver v2 with
trackVisibilityattempts to detect that. - Transforms, partially. The target's bounding box after transforms is used, so a
translateX(-200%)that moves an item out of a clipping parent is handled; but a 3D transform that makes an element face away is still "intersecting".
Comparison Table: Hiding Technique vs Observer Result
| Technique used to hide the fourth headline | isIntersecting |
Correct for impressions? | Fix |
|---|---|---|---|
Parent overflow: hidden, fixed height |
false | yes | none |
Parent clip-path: inset(...) |
true | no | switch to overflow clipping, or check manually |
Headline opacity: 0 |
true | no | skip entries whose target is transparent |
Headline visibility: hidden |
true | no | skip, or use display: none |
Headline display: none |
false | yes | none |
| Sticky header covering it | true | no | subtract header height via negative rootMargin |
Minimal Reproducible Example
<ul class="top-stories" style="block-size: 12rem; overflow: hidden">
<li>Story 1</li><li>Story 2</li><li>Story 3</li><li>Story 4</li><li>Story 5</li>
</ul>
<ul class="clipped" style="clip-path: inset(0 0 60% 0)">
<li>Item A</li><li>Item B</li><li>Item C</li>
</ul>
const io = new IntersectionObserver((es) => es.forEach((e) =>
console.log(e.target.textContent, e.isIntersecting, e.intersectionRatio.toFixed(2))));
document.querySelectorAll('li').forEach((li) => io.observe(li));
Stories 4 and 5 log false — the overflow clip is respected. Items B and C, visually cut off by clip-path, log true, 1.00.
Production-Safe Solution
Prefer layout-based clipping (overflow) for anything whose visibility you need to measure, and add explicit checks for the paint-level techniques the observer cannot see.
interface VisibilityOptions {
minRatio?: number;
stickyTop?: number; // px covered by a fixed/sticky header
}
export function observeTrulyVisible(
targets: Iterable<Element>,
onVisible: (el: Element) => void,
{ minRatio = 0.5, stickyTop = 0 }: VisibilityOptions = {},
): () => void {
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting || e.intersectionRatio < minRatio) continue;
const el = e.target as HTMLElement;
// Paint-level hiding the observer ignores; getComputedStyle is cheap here because
// it runs once per qualifying crossing, not per frame.
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || Number(cs.opacity) < 0.1) continue;
onVisible(el);
}
}, {
threshold: [0, minRatio],
rootMargin: `-${stickyTop}px 0px 0px 0px`, // treat the header's area as outside the root
});
for (const t of targets) io.observe(t);
return () => io.disconnect();
}
For containers that hide overflow with clip-path for visual effect (rounded masks, diagonal edges), wrap the content in an inner element with overflow: hidden matching the clip's rectangle. The observer then sees the geometric clip, and the clip-path remains a purely visual refinement.
Expand/Collapse Modules
The "More" button in the top-stories module changes the parent's height, not the headlines. Does the observer notice? Yes: changing the clip rectangle changes the computed intersection, and the next rendering update delivers entries for headlines that crossed a threshold — no scroll required. The same is true when a parent's overflow value changes, or when an accordion panel opens.
That makes clipped containers a convenient way to build "reveal more" UIs that are analytics-correct for free: impressions fire exactly when expansion exposes items. It also means an animation of the container's height produces a series of crossings as each item is exposed, which is fine for one-shot impressions (unobserve after the first) and noisy for anything continuous.
Verification Steps
- Log entries for each hiding technique in a test page and compare with the table above in each target browser.
- Expand and collapse clipped modules and confirm entries arrive without scrolling.
- Scroll under a sticky header and confirm items covered by it are not counted, thanks to the negative margin.
- Toggle
opacityon a target and confirm the style check filters it. - Check
clip-pathcomponents have an overflow-clipping inner wrapper if their visibility is measured.
Common Mistakes to Avoid
- Assuming the observer ignores
overflow: hidden. It respects it; hidden overflow is reported as not intersecting. - Hiding content with
clip-pathand measuring impressions. The observer cannot see paint-level clipping. - Counting transparent elements. Opacity is invisible to the observer.
- Forgetting sticky headers. Content under them is geometrically inside the viewport.
FAQ
Does IntersectionObserver respect overflow: hidden on a parent?
Yes. Every ancestor with a content clip — overflow hidden, clip, auto or scroll — clips the target before it is intersected with the root, so content hidden by the parent reports as not intersecting.
Why is my clip-path-hidden content reported as visible?
clip-path affects only painting. The observer works on layout geometry, so the element's box is still considered present. Use overflow clipping for anything whose visibility you measure.
Can IntersectionObserver detect elements covered by a modal?
Not with the standard API. IntersectionObserver v2's trackVisibility option reports isVisible false when a target is occluded or transformed, at a performance cost and with limited browser support.
Does overflow: clip behave the same as overflow: hidden here?
Yes for the observer: both create a content clip. The difference is that overflow: clip forbids programmatic scrolling and does not create a scroll container.
Does an element translated outside its clipping parent count as hidden?
Yes. The observer uses the target's bounding box after transforms, so an item moved out of an overflow-hidden parent with translateX is clipped away and reported as not intersecting — which is exactly how transform-based carousels hide off-screen slides.
Is getComputedStyle in the callback a performance problem?
Not when it runs once per qualifying crossing, because layout and style are already clean when the callback runs. It becomes a problem only in a loop that also writes styles, where each read after a write forces style recalculation.
How do I exclude the area under a fixed header?
Use a negative top rootMargin equal to the header height with the implicit root. Content under the header is then outside the root rectangle, and entries reflect what is actually readable.
Related
- Using a Scroll Container as IntersectionObserver Root — clipping versus explicit roots
- IntersectionObserver v2: Tracking Visibility Support — occlusion-aware visibility
- Impression Tracking for Product Carousels — clipped containers in analytics
↑ Back to IntersectionObserver API Deep Dive