rootMargin: '50%' is valid, accepted without complaint, and frequently does something other than what the author intended. The rules are short; the surprises come from which box the percentage resolves against.

Problem / Scenario Context

A scroll-spy implementation uses a centred detection band and expresses it as a percentage so it adapts to the viewport:

TypeScript
new IntersectionObserver(onSection, { rootMargin: '-40% 0px -40% 0px' });

It works on a laptop. On a phone the band is far too narrow and sections flicker between active states; in a short scrollable panel used as the root, no section is ever active at all.

The margin is not being ignored. It is resolving against a different box than the author had in mind, and in the panel case it is producing a negative band taller than the panel itself.

Mechanics Explanation

Three rules explain nearly every surprise.

Percentages resolve against the root, not the target. A vertical percentage uses the root's height; a horizontal one uses its width. With root: null the root is the viewport, so -40% on a 900-pixel-tall viewport is 360 pixels, and on a 640-pixel phone it is 256. The band is not a constant band; it scales with the device.

Horizontal and vertical percentages use different bases. rootMargin: '10%' expands top and bottom by ten per cent of the height and left and right by ten per cent of the width. On a wide, short viewport those are very different numbers, which is why a single-value percentage margin rarely produces a symmetric-looking box.

A cross-origin root rejects the margin entirely. When the target is in a different document from the root — the iframe case — the browser refuses to apply rootMargin at all, because doing so would leak information about the embedder's dimensions across the origin boundary.

There is also a plain validity rule that catches people: only px and % are accepted. em, rem, vh and unitless non-zero values throw a SyntaxError at construction.

The Same Percentage, Two Devices, Two BandsA negative forty per cent vertical margin drawn on two viewports. On a 900 pixel desktop viewport it removes 360 pixels from each end, leaving a 180 pixel detection band. On a 640 pixel phone viewport it removes 256 pixels from each end, leaving a 128 pixel band. The band scales with the device, which is the behaviour a fixed pixel margin would not have.rootMargin: '-40% 0px -40% 0px'desktop — 900px tall180px band-360pxphone — 640px tall128px band-256pxthe base is the ROOTvertical % → root heighthorizontal % → root widthnever the target's own size

Comparison Table: Units and Roots

rootMargin root: null root: element Cross-origin root
'200px' 200px each side 200px each side ignored
'10%' 10% of viewport h/w 10% of element h/w ignored
'-40% 0px' scales with viewport height scales with element height ignored
'2em' SyntaxError SyntaxError SyntaxError
'5vh' SyntaxError SyntaxError SyntaxError
'0' valid — zero is unitless-legal valid valid

Minimal Reproducible Example

TypeScript
// Looks device-independent, is not
const io = new IntersectionObserver(cb, { rootMargin: '-40% 0px -40% 0px' });
console.log(io.rootMargin);      // "-40% 0px -40% 0px" — normalised, but still relative

// Throws immediately
new IntersectionObserver(cb, { rootMargin: '5vh' });
// SyntaxError: Failed to construct 'IntersectionObserver':
// rootMargin must be specified in pixels or percent.

Production-Safe Solution

Decide first whether the band should be proportional or fixed, because that is the real question behind the units.

For a scroll-spy band that should feel the same on every device, a fixed pixel band computed from the viewport is more predictable than a percentage, and can be recomputed when the viewport changes:

TypeScript
function bandMargin(): string {
  const h = window.innerHeight;
  const band = Math.max(120, Math.min(260, Math.round(h * 0.18)));   // clamp the extremes
  const edge = Math.round((h - band) / 2);
  return `-${edge}px 0px -${edge}px 0px`;
}

let spy: IntersectionObserver | null = null;
function rebuildSpy(): void {
  spy?.disconnect();                                  // rootMargin is immutable after construction
  spy = new IntersectionObserver(onSection, { rootMargin: bandMargin(), threshold: 0 });
  document.querySelectorAll('section[id]').forEach((s) => spy!.observe(s));
}

rebuildSpy();
let t: number | undefined;
addEventListener('resize', () => { clearTimeout(t); t = window.setTimeout(rebuildSpy, 200); });

The clamp is what makes this better than a bare percentage: on a very short viewport a proportional band collapses to a few pixels and no section ever qualifies, and on a very tall one it becomes so generous that two sections are active at once.

Note that rootMargin cannot be changed on an existing observer — the property is read-only — so adapting means constructing a new one, which is exactly the kind of churn a pool handles cleanly if several features need the same band.

Where a percentage genuinely is the right expression — a prefetch margin of "one viewport ahead", say — it is both correct and self-documenting:

TypeScript
// "start loading when the target is within one full viewport of the fold"
{ rootMargin: '100% 0px' }

Proportional or Fixed? The Question Behind the UnitsTwo intents mapped to the right expression. A margin that should scale with the device, such as prefetching one viewport ahead, is naturally a percentage. A margin that should feel constant, such as a scroll-spy detection band, is better expressed as pixels computed from the viewport and clamped at both ends, then rebuilt when the viewport changes.should scale with the device"prefetch one viewport ahead"rootMargin: '100% 0px'a percentage says exactly what is meant,and adapts for freeno rebuild needed on resizeshould feel constant"a detection band around the middle"clamp(120, h * 0.18, 260) → pxa bare percentage collapses on shortviewports and over-selects on tall onesrebuild the observer on resizerootMargin is read-only after construction, so "adapting" always means building a new observer.

Negative margins have a floor

One further constraint catches people building centred bands: a negative margin cannot shrink the root box below zero. Asking for -60% 0px -60% 0px requests a total reduction of 120 per cent of the root's height, which leaves no intersection rectangle at all, and the observer will simply never report an intersection for anything. There is no warning; the configuration is syntactically valid and behaviourally inert. Keeping the two vertical components summing to less than 100 per cent is the rule, and clamping them in code is cheaper than discovering it from a silent feature.

Verification Steps

  • Read it back: observer.rootMargin returns the normalised four-component string, which confirms what the browser parsed.
  • Compute the resolved band by logging window.innerHeight alongside the percentage and checking the arithmetic matches where callbacks actually fire.
  • Test at two viewport heights — a phone and a tall desktop — rather than one.
  • Check the cross-origin case explicitly if the target may end up in an iframe; the margin is silently dropped, not warned about.
  • Confirm the rebuild on resize by resizing and checking observer.rootMargin changed.

Reading Back What the Browser ParsedThe rootMargin property returns the normalised four-component string the browser actually stored, which is the quickest way to confirm a shorthand expanded as intended. Three inputs are shown with the value the property returns for each, including one that throws instead.observer.rootMargin tells you what was actually parsed"200px""200px 200px 200px 200px""10% 0px""10% 0px 10% 0px""5vh"SyntaxError at constructionThe third never reaches a callback, which is why it reads as an observer that was never created.

Common Mistakes to Avoid

  • Assuming the percentage resolves against the target. It never does.
  • Using one value for a percentage margin. Vertical and horizontal use different bases, so '10%' is rarely symmetric.
  • Using vh, em or rem. They throw at construction; only px and % are valid.
  • Trying to mutate rootMargin. It is read-only; construct a new observer instead.
  • Expecting a margin to apply across an origin boundary. It is dropped deliberately.

FAQ

What does a percentage rootMargin resolve against?

The root's dimensions — the viewport when root is null, otherwise the root element's box. Vertical components use the root's height and horizontal components use its width. It is never relative to the observed target, which is the assumption behind most surprises.

Why does rootMargin: '10%' produce an asymmetric-looking box?

Because the top and bottom components resolve against the root's height while the left and right resolve against its width. On a wide, short viewport those are very different numbers, so a single-value percentage expands the box far more horizontally than vertically.

Can I use vh or em units in rootMargin?

No. Only pixels and percentages are accepted, and anything else throws a SyntaxError at construction. This is a common cause of an observer that appears never to be created, because the exception fires during setup rather than at the first callback.

Why is my rootMargin ignored inside an iframe?

When the root and the target are in different origins, the browser refuses to apply the margin, because honouring it would reveal the embedder's dimensions across the origin boundary. The observation still works; the expansion is silently dropped.


↑ Back to IntersectionObserver API Deep Dive