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:

  • isIntersecting is true if 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?"
  • intersectionRatio is 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 is 1 if isIntersecting is true and 0 otherwise. 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.

Three Targets, Two FieldsA viewport with three targets. A normal card half inside the viewport has isIntersecting true and a ratio of 0.5. A card whose top edge exactly touches the viewport's bottom edge has isIntersecting true and a ratio of 0. A zero-height wrapper inside the viewport has isIntersecting true and a ratio of 1 by definition.half inside: isIntersecting true, ratio 0.5edge-adjacent: isIntersecting true, ratio 0zero-area wrapper: true, ratio 1 by definitionSolid blue frame: viewport (root).

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

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

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

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

Which Field Answers Your Question?A decision chain. If the question is whether to start loading or preparing something, check isIntersecting. Otherwise, if the question is whether real pixels are on screen, check isIntersecting together with a non-empty intersection rectangle. Otherwise, if the question is how much of the element is visible, use intersectionRatio with an explicit threshold. Otherwise, for fully visible checks on possibly oversized targets, compare against the maximum possible visible area.Start loading or preparing something?isIntersecting — includes zero-area and edgecontactyesnoAre real pixels on screen?isIntersecting and a non-empty intersectionRectyesnoHow much is visible, for a normal-sized element?intersectionRatio with matching thresholdsyesno"Fully visible" for possibly oversized targets: divide by the maximum visible area, not the element's area.

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.

Maximum Possible Ratio by Target HeightA line chart of the maximum intersection ratio a target can reach in a 700 pixel tall viewport as its height grows. Up to 700 pixels the maximum is 1. Beyond that it falls: 0.5 at 1400 pixels and about 0.35 at 2000 pixels. A threshold of 1 or 0.5 is therefore unreachable for sufficiently tall targets.00.250.50.7510400800120016002000target height in px (700 px viewport)maximum intersectionRatiomaximum reachable ratio

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 rootBounds is non-null; it is null for cross-origin iframe roots, which changes what visibleFraction can compute.

Common Mistakes to Avoid

  • intersectionRatio > 0 for lazy loading. It misses edge-adjacent entries and confuses zero-area targets.
  • isIntersecting alone for impressions. It counts elements with zero visible pixels.
  • intersectionRatio === 1 for 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.


↑ Back to IntersectionObserver API Deep Dive