Check isIntersecting to decide whether a target is inside the root — it is correct even for zero-area and edge-touching elements — and use intersectionRatio only to decide how much, remembering that it can be 0 for a target that is technically intersecting.
Problem / Scenario Context
A lazy loader written as if (entry.intersectionRatio > 0) load(entry.target) works on every image in a gallery except one: a 1×1 tracking pixel wrapper that never fires. A second component, an analytics tracker, uses if (entry.isIntersecting) and double-counts a banner that sits exactly at the bottom edge of the viewport, because it reports as intersecting while nothing of it is visible. A third component, a "fully visible" badge, checks intersectionRatio === 1 and never shows for a hero image taller than the screen.
All three are misreadings of the same two fields. The IntersectionObserver API Deep Dive explains how entries are computed; this page is about reading them.
Mechanics Explanation
For each target, the browser computes an intersection rectangle: the target's bounding box clipped by its ancestors' overflow and by the root rectangle (adjusted by rootMargin). From that:
isIntersectingistrueif the target and the root intersect or are edge-adjacent — even when the intersection rectangle has zero area. It answers the geometric question "are they touching?"intersectionRatiois the intersection rectangle's area divided by the target's bounding area. If the target itself has zero area (a 0×0 element or an empty inline element), the ratio is1ifisIntersectingis true and0otherwise. For normal elements it is a continuous value in[0, 1].
Two consequences follow. An element whose edge exactly touches the root edge has isIntersecting: true and intersectionRatio: 0. And a target taller than the root can never have a ratio of 1 — its maximum ratio is the root height divided by the target height.
The browser also only delivers entries when a threshold is crossed. With the default threshold: 0, crossings are "starts touching" and "stops touching", so isIntersecting is exactly the field that changed.
Comparison Table: The Fields in Edge Cases
| Situation | isIntersecting |
intersectionRatio |
Safe check |
|---|---|---|---|
| Target fully inside | true | 1 | either |
| Target partly inside | true | between 0 and 1 | either |
| Target edge exactly on root edge | true | 0 | isIntersecting for touching; ratio for visible |
| Zero-area target inside root | true | 1 | isIntersecting |
| Zero-area target outside root | false | 0 | isIntersecting |
| Target taller than root, centred | true | root ÷ target (below 1) | threshold 0 or compare to max possible |
Target hidden with display: none |
false | 0 | either — never intersects |
Minimal Reproducible Example
// A banner whose top edge sits exactly on the viewport's bottom edge at load.
const banner = document.querySelector('.promo-banner')!;
new IntersectionObserver(([e]) => {
console.log({ isIntersecting: e.isIntersecting, ratio: e.intersectionRatio });
if (e.isIntersecting) console.log('impression!'); // fires with zero visible pixels
if (e.intersectionRatio > 0) console.log('loaded'); // does not fire for the same entry
}).observe(banner);
Position the banner so its top is exactly at innerHeight and reload: the first entry logs isIntersecting: true, ratio: 0. The impression counter fires for a banner nobody can see, while the ratio-based loader does nothing — the two fields disagree, and each component picked the wrong one for its purpose.
Production-Safe Solution
Pick the field by the question being asked, and write it down as a small set of helpers so every component asks it the same way.
/** Touching the root at all, including zero-area targets and exact edge contact. */
export const isTouching = (e: IntersectionObserverEntry): boolean => e.isIntersecting;
/** Some pixels of the target are actually inside the root. */
export const hasVisiblePixels = (e: IntersectionObserverEntry): boolean =>
e.isIntersecting && (e.intersectionRect.width > 0 && e.intersectionRect.height > 0 ||
e.boundingClientRect.width === 0 || e.boundingClientRect.height === 0);
/** "Fully visible" that works for targets taller or wider than the root. */
export function visibleFraction(e: IntersectionObserverEntry): number {
const root = e.rootBounds;
if (!root || !e.isIntersecting) return 0;
const maxW = Math.min(e.boundingClientRect.width, root.width);
const maxH = Math.min(e.boundingClientRect.height, root.height);
const maxArea = maxW * maxH;
const area = e.intersectionRect.width * e.intersectionRect.height;
return maxArea > 0 ? area / maxArea : 1; // 1 = as visible as it can possibly be
}
Then each component uses the helper that matches its intent:
// Lazy loading: start as soon as it touches the (margin-expanded) root.
const lazy = new IntersectionObserver((es, obs) => es.forEach((e) => {
if (isTouching(e)) { load(e.target); obs.unobserve(e.target); }
}), { rootMargin: '400px 0px' });
// Analytics: count only when real pixels are on screen.
const impressions = new IntersectionObserver((es) => es.forEach((e) => {
if (hasVisiblePixels(e) && e.intersectionRatio >= 0.5) record(e.target);
}), { threshold: [0, 0.5] });
// "Fully in view" badge that works for tall heroes.
const badge = new IntersectionObserver((es) => es.forEach((e) => {
e.target.classList.toggle('fully-visible', visibleFraction(e) > 0.99);
}), { threshold: [0, 0.25, 0.5, 0.75, 0.99, 1] });
declare function load(el: Element): void;
declare function record(el: Element): void;
Why Thresholds and Fields Must Agree
A check can only be as precise as the thresholds that produce entries. Entries are delivered when the ratio crosses a threshold value, so a callback checking intersectionRatio >= 0.5 with the default threshold: 0 sees entries only at 0 — it will observe a ratio of, say, 0.02 on the way in and never be called again as the element scrolls fully into view. The check is correct; it simply never gets the data.
The rule is mechanical: every ratio value your callback compares against must be in the threshold list, or be reachable by a threshold at or just before it. For >= 0.5, include 0.5. For "fully visible" on normal elements, include 1 (and consider 0.99, since sub-pixel rounding can keep the ratio just below 1). For oversized targets, derive the thresholds from the maximum possible ratio, or observe a smaller sentinel element instead of the whole target.
The threshold guide works through the crossing rules in detail.
Verification Steps
- Log both fields for every entry during development; disagreements show you which edge case a target falls into.
- Test a zero-height wrapper and an element placed exactly at the viewport's bottom edge.
- Test a target taller than the viewport with any "fully visible" logic.
- Confirm the thresholds include every ratio your callback compares against.
- Check
rootBoundsis non-null; it isnullfor cross-origin iframe roots, which changes whatvisibleFractioncan compute.
Common Mistakes to Avoid
intersectionRatio > 0for lazy loading. It misses edge-adjacent entries and confuses zero-area targets.isIntersectingalone for impressions. It counts elements with zero visible pixels.intersectionRatio === 1for tall elements. It can be impossible to reach.- Ratio checks without matching thresholds. The callback never receives the value it is waiting for.
FAQ
Can isIntersecting be true while intersectionRatio is 0?
Yes. When the target is edge-adjacent to the root — touching but not overlapping — the intersection rectangle has zero area, so the ratio is 0, but the spec defines the target as intersecting.
Why does a zero-height element report a ratio of 1?
Because the ratio would otherwise divide by zero. The specification sets it to 1 when the zero-area target is intersecting and 0 when it is not, which makes it behave like a point that is either inside or outside.
Is isIntersecting supported everywhere?
Yes in all current browsers. Very old Edge versions shipped IntersectionObserver without isIntersecting, which is why some older code checks intersectionRatio > 0 instead; that fallback is no longer needed.
Should I check the last entry or every entry for a target?
If several entries for the same target arrive in one callback, they are in time order, and only the last reflects the current state. Loop over them for event-style logic, but use the last for state.
Does rootMargin affect intersectionRatio?
Yes. The ratio is computed against the margin-adjusted root rectangle, so a positive margin makes the ratio reach 1 before the element is actually on screen. For visibility metrics, observe with no margin; for loading, margins are fine because isIntersecting is what matters.
Related
- How IntersectionObserver Threshold Works in Practice — when entries are delivered
- Using a Scroll Container as IntersectionObserver Root — changing what the root is
- Tracking Ad Visibility for Analytics Compliance — ratio checks in a real metric
↑ Back to IntersectionObserver API Deep Dive