Configuring when an element counts as visible
IntersectionObserver watches when target elements enter or leave a root
viewport and reports it asynchronously, replacing janky scroll listeners. Its
behaviour is shaped by three constructor options and read through the
IntersectionObserverEntry passed to your callback. This reference documents
both, with a live validator for the tricky rootMargin string.
How it works
You create an observer with options, then observe one or more targets:
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) loadImage(entry.target);
}
},
{ root: null, rootMargin: "0px 0px -100px 0px", threshold: [0, 0.5, 1] }
);
io.observe(document.querySelector(".lazy"));
root defines the viewport (the browser viewport when null). rootMargin
expands or contracts that box — a negative bottom margin makes the callback fire
only once the target is well inside view. threshold lists the visibility ratios
that trigger the callback, so you can react at 0%, 50% and 100% visibility.
The three options in depth
root
The root is the element that acts as the viewport. Pass null (the default) to
use the browser’s actual viewport. Pass a DOM element (such as a scrollable
container) to observe visibility within that element. The target must be a
descendant of the root.
rootMargin
rootMargin is a CSS-margin-like string that expands or shrinks the root’s
bounding box before intersection is computed. It accepts one to four values in
px or % only — no other units, no bare numbers. The order is top right bottom left, the same as CSS margin shorthand.
Common patterns:
"0px" // no change — fire when any pixel enters the viewport
"0px 0px -200px 0px" // shrink the bottom by 200px — fire only once 200px inside
"100px" // expand all sides by 100px — fire 100px before element enters
The negative-bottom pattern is the most useful for lazy-loading: it ensures images are loaded when they are 200 px away from entering view, avoiding a visible load flash.
threshold
threshold is a number or array of numbers between 0 and 1 representing the
visibility ratio at which callbacks fire.
0— fire as soon as a single pixel is visible (the default)1— fire only when the element is fully visible within the root[0, 0.25, 0.5, 0.75, 1]— fire at each 25% visibility step
Use a threshold array to drive scroll-based animations: at each step you receive
the entry’s intersectionRatio and can update CSS variables, opacity, or
transforms.
IntersectionObserverEntry properties
Each callback invocation passes an array of IntersectionObserverEntry objects.
Key properties:
| Property | Type | What it tells you |
|---|---|---|
isIntersecting | boolean | Whether the target currently overlaps the root |
intersectionRatio | number 0–1 | Fraction of the target visible within the root |
boundingClientRect | DOMRectReadOnly | The target’s bounding box |
intersectionRect | DOMRectReadOnly | The visible portion of the target |
rootBounds | DOMRectReadOnly | The root’s bounding box after margin |
target | Element | The observed element |
time | DOMHighResTimeStamp | When the change was detected |
Tips and notes
rootMarginusespxor%only — never bare numbers or other units.- Pass a
thresholdarray to animate smoothly as an element scrolls through view. - Read
intersectionRatiofor how much is visible andboundingClientRectfor where it sits. - Call
io.unobserve(target)after a one-shot trigger (like lazy-load) to stop further callbacks. - Observe multiple elements on a single observer instance — more efficient than creating one observer per element.
IntersectionObserveris widely supported in all modern browsers; check compatibility only if you need to support very old environments.