Set root to the scrollable element whose visible area you care about — a carousel track, a sidebar, a modal body — and make sure every target is a descendant of it; visibility is then measured against that element's padding box (plus rootMargin), not the page viewport.
Problem / Scenario Context
A product page has an image carousel: a horizontally scrolling track of slides. The team wants each slide's high-resolution image to start loading one slide before it is swiped into view, so it is ready when it arrives. They add rootMargin: '0px 100% 0px 100%' to the lazy-loading observer — and nothing changes. Slides still load only when they are already on screen, and users see a blurry placeholder on every swipe.
The margin was applied to the page viewport, which the carousel's next slide was already inside horizontally; what hid the slide was the track's clipping, which the viewport margin cannot touch. The IntersectionObserver API Deep Dive introduces root; this page covers using it with scroll containers.
Mechanics Explanation
With the implicit root, the browser clips each target by every scrolling ancestor, then intersects what is left with the viewport. A slide scrolled out of the track's visible area is clipped away entirely before the viewport — and its margin — are even considered. That clipping is correct for "is it visible on screen", and exactly wrong for "is it about to be".
Passing the track as root moves the frame of reference to the track itself, which has a second benefit: it makes the frame explicit. Layouts change — a pane that did not scroll on a large monitor scrolls on a small one — and code that assumes a frame of reference without stating it breaks quietly when they do.
- With
root: pane, the root rectangle is the pane's padding box (its content plus padding, excluding border and scrollbar), androotMarginexpands or shrinks that. Targets must be descendants of the pane, or the observer reports them as never intersecting. - With the implicit root, the root rectangle is the viewport, targets are clipped by all scrolling ancestors, and a pane that is itself partly off-screen clips further.
rootMarginonly applies to the root. With the implicit root, it does not expand intermediate scrollers' clip rects — which is why arootMarginmeant to pre-load items in a carousel does nothing unless the carousel is the root.
Comparison Table: Root Choices
| Root | Root rectangle | rootMargin percentages relative to |
Typical use |
|---|---|---|---|
null (implicit) |
top-level viewport | viewport size | page-level lazy loading |
document |
that document's viewport | viewport size | observing inside an iframe relative to the iframe |
| scrollable element | element's padding box | element's size | carousels, panes, modals |
| non-scrolling element | element's padding box | element's size | clipping regions (overflow: hidden) |
| element that is not an ancestor of targets | — | — | never intersects: a bug |
Minimal Reproducible Example
// Pre-load carousel slides one slide ahead — but the margin applies to the viewport.
const track = document.querySelector<HTMLElement>('.carousel-track')!; // overflow-x: auto
const io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) loadSlide(e.target);
}, { rootMargin: '0px 100% 0px 100%' }); // meant to reach the next slide
track.querySelectorAll('.slide').forEach((s) => io.observe(s));
declare function loadSlide(el: Element): void;
The next slide is clipped by the track, and the viewport margin cannot un-clip it. Slides only load when they scroll into the track's visible area.
Production-Safe Solution
interface ContainerObserverOptions {
container: HTMLElement; // the scrolling element
targets: Iterable<Element>;
ahead?: string; // how far outside the container to start, e.g. '100%'
onEnter: (el: Element) => void;
axis?: 'x' | 'y';
}
export function observeInContainer({ container, targets, ahead = '0px', onEnter, axis = 'y' }: ContainerObserverOptions) {
const margin = axis === 'x' ? `0px ${ahead} 0px ${ahead}` : `${ahead} 0px ${ahead} 0px`;
const io = new IntersectionObserver((entries, obs) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
onEnter(e.target);
obs.unobserve(e.target);
}
}, { root: container, rootMargin: margin, threshold: 0 });
for (const t of targets) {
if (!container.contains(t)) {
console.warn('target is not inside the root; it will never intersect', t);
continue;
}
io.observe(t);
}
return () => io.disconnect();
}
// Carousel: pre-load one full track-width ahead in both directions.
observeInContainer({
container: document.querySelector('.carousel-track')!,
targets: document.querySelectorAll('.carousel-track .slide'),
ahead: '100%',
axis: 'x',
onEnter: (el) => loadSlide(el),
});
Percentages in rootMargin resolve against the root's own size, so 100% means "one container-width ahead" regardless of screen size. The contains check turns the silent failure — a target outside the root never intersects — into a visible warning.
The same helper covers other panes too: for read receipts in a support dashboard's scrollable conversation, passing the conversation pane as container and ahead: '-10%' marks messages read only once they are comfortably inside the pane.
Nested Scrollers and Modals
Real layouts nest scrollers: a scrollable modal inside a scrollable page, a carousel inside a scrollable feed. With a container root, clipping by intermediate scrollers between the root and the target still applies, and the root itself is clipped by nothing — its own position on the page does not matter. That has two consequences:
- A carousel far below the fold is "visible" to its own observer. If the carousel's track is the root, its first slide intersects the root immediately, even if the whole carousel is off-screen. Lazy loading then fetches images for a carousel nobody has scrolled to. Combine two observers — one with the implicit root to know whether the carousel is near the viewport, and one with the track as root to choose which slides — and only start the second once the first reports true.
- Modal content observes relative to the modal. A modal body as root makes behaviour independent of the page behind it, which is usually what you want for infinite lists inside dialogs; see infinite scroll inside a scrollable container.
// Gate the slide observer on the carousel being near the viewport.
const gate = new IntersectionObserver(([e]) => {
if (!e.isIntersecting) return;
gate.disconnect();
observeInContainer({ container: track, targets: track.querySelectorAll('.slide'), ahead: '100%', axis: 'x', onEnter: loadSlide });
}, { rootMargin: '300px 0px' });
gate.observe(track);
declare const track: HTMLElement;
Verification Steps
- Log
entry.rootBoundsand confirm it matches the container's padding box, not the window. - Scroll the page without scrolling the container and confirm no new entries arrive for its children (the root does not move relative to them).
- Scroll the container and confirm items load one margin ahead.
- Resize the container and confirm percentage margins scale with it.
- Move a target outside the container in DevTools and confirm the warning appears.
Common Mistakes to Avoid
- Expecting
rootMarginon the implicit root to reach inside scrollers. It only expands the root. - Observing targets that are not descendants of the root. They never intersect, silently.
- Using a container root for off-screen widgets without a gate. Their contents all load immediately.
- Assuming percentages are relative to the viewport. With a container root, they are relative to the container.
FAQ
Does the root element have to be scrollable?
No. Any element that is an ancestor of the targets works. A non-scrolling element with overflow hidden acts as a clipping frame; one without overflow clipping simply defines a rectangle.
Is the root's border or scrollbar included?
No. The root intersection rectangle is the padding box when the root has overflow clipping — content plus padding, excluding border and scrollbar.
What is the document root option for?
Passing a Document as root (commonly the iframe's own document) makes rootMargin apply to that document's viewport. It exists mainly so code inside iframes can use margins, which the implicit root ignores for cross-origin iframes.
Can several observers share one container root?
Yes. Create one observer per distinct set of options. Observers with the same root and options can be merged into one, which is the pooling approach described in the shared observer pooling topic.
Does the container's own visibility on the page affect its observer?
No. The observer only compares targets with the container's rectangle. Whether the container itself is on screen is invisible to it, which is why gating with a second observer is needed for off-screen containers.
Related
- isIntersecting vs intersectionRatio: Which to Check — reading the entries
- Detecting Element Visibility Inside overflow: hidden Parents — clipping without scrolling
- rootMargin Not Working with Percentages — margin units in depth
↑ Back to IntersectionObserver API Deep Dive