skip to content

An IntersectionObserver is constructed with { threshold: [0, 0.5, 1] }. When exactly does its callback run as the user scrolls, and what does entry.intersectionRatio mean at that moment?

level: middleimportance: must knowfreq 58%

answer

  1. tripwires, not a live feed
  2. crossings in both directions
  3. fraction of the target's own area
  4. boxes, not what the eye sees
  5. a tall target never reaches 1

basics

~20 s

The callback runs whenever the visible fraction of the target crosses one of the listed thresholds, in either direction — not continuously while scrolling. intersectionRatio is that fraction: the intersecting area divided by the target's own bounding-box area, between 0 and 1.

solid answer

~50 s

`threshold` is a list of tripwires expressed as a fraction of the *target's* area, and the observer is edge-triggered: it delivers an entry when the ratio crosses one of those values going up or coming back down, not on every scroll frame. With `[0, 0.5, 1]` you get a report as the element first touches the root, as it passes half visible, and as it becomes fully visible — plus the mirror crossings on the way out. `intersectionRatio` is `intersectionArea / targetArea`, so it is between 0 and 1, never a percentage; because delivery is sampled per frame, the reported value overshoots the threshold slightly. Two gotchas: the ratio is pure geometry, so an element covered by an opaque overlay still reports `isIntersecting: true`; and a target taller than the root can never reach ratio 1, so a `1` threshold silently never fires.

code

javascript · 7 lines
javascript
const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    console.log(entry.target.id, entry.isIntersecting, entry.intersectionRatio.toFixed(2));
  }
}, { threshold: [0, 0.5, 1] });

document.querySelectorAll('section').forEach((el) => io.observe(el));

go deeper

for a junior

Remember that threshold values are fractions between 0 and 1, that 0 means 'any overlap at all', and that you read how much is showing from entry.intersectionRatio.

for a middle

Explain edge triggering in your own words — entries arrive on crossings in both directions, not on every scroll frame — and that the ratio divides the intersecting area by the target's own area.

for a senior

Demonstrate the geometry-only judgment: isIntersecting is not proof the user saw anything, overlays and opacity do not count, and a threshold of 1 is unreachable for targets bigger than the root. Say how you would diagnose an observer that never fires.

for a principal

Own the tradeoff between a coarse threshold list and a fine one: more thresholds means more callback work per scroll for smoother output. Set a house default and require a stated reason before a component asks for a twenty-step ladder.

## The two option values that decide when you hear anything An `IntersectionObserver` compares the target's bounding box against the root's box, but it does not tell you about every pixel of change. It tells you when the *ratio* of overlap crosses a value you nominated. `threshold` accepts a single number or an array of numbers, each between 0 and 1 inclusive. Values outside that range are rejected: the constructor throws a `RangeError`, so `threshold: 50` meaning "50%" is a bug that fails loudly rather than quietly. The default is `0`, which means "tell me the moment any part of the target overlaps the root, and again the moment it stops overlapping". ## Edge-triggered, not level-triggered This is the single most important behaviour to state in an interview. With `{ threshold: 0.5 }` the callback does **not** run continuously while more than half the element is on screen. It runs at the crossing — once as the ratio rises past 0.5, and once as it falls back below. Between crossings, the observer is silent no matter how much the user scrolls. That is a feature: it is what makes the API cheap compared with a scroll handler. It is also the source of the classic stall bug, where an element ends up parked inside the root, never crosses again, and the code that was waiting for "another callback" waits forever. If you want smooth progress — a scroll-linked progress bar, a fade proportional to visibility — you supply many thresholds: ```js const steps = Array.from({ length: 21 }, (_, i) => i / 20); // 0, 0.05, ... 1 new IntersectionObserver(onStep, { threshold: steps }); ``` Even then you get discrete samples, not a continuous stream, and each entry may report a ratio a little past the tripwire because measurement happens once per rendering update, not at the exact instant of the crossing. ## What the ratio is a ratio of `intersectionRatio` is the area of `intersectionRect` divided by the area of `boundingClientRect` — the **target's** area, not the root's. A small badge fully inside a large viewport has ratio 1; a huge hero banner half inside has ratio 0.5. The value is a fraction from 0 to 1; multiply by 100 yourself if you want a percentage. The spec handles the degenerate case explicitly: when the target has zero area, the ratio is defined as 1 when it intersects and 0 when it does not — which is why a zero-height sentinel `<div>` can still be reported as intersecting. ## Geometry only — what it does not know Intersection is computed from boxes and clipping, and deliberately not from what the user can actually perceive: - An element sitting under an opaque modal or another stacked element still reports `isIntersecting: true`. - `visibility: hidden` and `opacity: 0` do not change the answer; the element still generates a box. - `display: none` **does** change it: there is no box at all, so the element never intersects. - Clipping by an intervening scroll container or `overflow: hidden` ancestor *is* taken into account, because the intersection rectangle is clipped along the way. - A backgrounded tab does not turn intersection off; the state simply stops changing. So `isIntersecting` means "in the box", not "seen". If you need a genuine visibility signal, IntersectionObserver v2 adds `trackVisibility: true` with a required `delay`, and exposes `entry.isVisible` — but that extension is Chromium-only, so anything cross-browser must treat it as an enhancement, not a foundation. ## The threshold-1 trap `threshold: 1.0` reads as "fire when the element is fully visible", and it does exactly that — for elements that *can* be fully visible. If the target is taller than the root (a full-bleed section against the viewport, a long card inside a short scroll container), the ratio's ceiling is `rootHeight / targetHeight`, which is less than 1, so the tripwire is unreachable and the callback never fires for it. The fix is either a reachable threshold or a design that does not depend on wholeness; the diagnosis is to log `intersectionRatio` with a `threshold: 0` observer and see what the maximum actually is. ## Putting it together ```js const io = new IntersectionObserver((entries) => { for (const entry of entries) { // ratio is 0..1 and slightly past the tripwire that fired entry.target.style.setProperty('--visible', String(entry.intersectionRatio)); } }, { threshold: [0, 0.25, 0.5, 0.75, 1] }); ``` When you describe this in an interview, lead with "crossings, not a stream", follow with "ratio is a fraction of the target", and finish with the geometry-only caveat. Those three sentences cover almost everything the question is testing.

  • What happens if you pass threshold: 50, meaning 50 percent?
    The constructor throws a `RangeError`. Thresholds are fractions between 0 and 1 inclusive, so half visible is `0.5`. It is a loud failure rather than a silent misconfiguration, which is useful — the observer is never created, so the bug surfaces at setup rather than as a feature that mysteriously never triggers.
  • A card is inside the viewport but completely hidden behind an opaque modal. What does the observer report?
    `isIntersecting: true`, with a ratio driven purely by geometry. Intersection is computed from boxes and clipping, not from stacking, painting or perceptibility, so overlays, `opacity: 0` and `visibility: hidden` are all invisible to it. Only `display: none`, which removes the box entirely, makes the element stop intersecting.
  • Why might the reported ratio be 0.53 when your threshold was 0.5?
    Because intersections are sampled as part of the browser's rendering work, not at the exact instant the geometry crosses the line. The entry reports the state at sample time, which is usually a little past the tripwire — more so during fast scrolling. Treat the threshold as "at least", and never assert exact equality on the ratio.

Thresholds are tripwires strung across a doorway, not a camera pointed at it: you get a signal each time something crosses a wire, and silence while it stands still in the room.

saying these in an interview costs you the question

  • Says threshold 0.5 fires continuously while half the element shows
  • Thinks intersectionRatio is a 0-100 percentage
  • Believes an element behind an overlay reports isIntersecting false
  • Expects threshold 1 to fire for a target taller than the root
  • Computes the ratio against the viewport's area instead of the target's

context