Pinch-zoom changes only the visual viewport, and IntersectionObserver measures against the layout viewport, so zooming in never changes an observer's results — if you need "actually on screen while zoomed", intersect the entry's rectangle with window.visualViewport yourself.

Problem / Scenario Context

A news site counts an ad impression when at least half of the ad has been in view for one second, following the approach in tracking ad visibility for analytics compliance. An auditor zooms into a paragraph at 300% on a phone, and the logs still record a viewable impression for the ad in the sidebar — which, at that zoom level, is completely off-screen.

The observer is behaving as specified. The question is whether your metric is supposed to follow the specification's notion of viewport or the user's. The Visual Viewport API & Mobile Viewport Units topic explains the two rectangles; this page applies them to visibility.

Mechanics Explanation

Pinch-zoom on mobile (and trackpad pinch on desktop Safari and Chrome) is visual zoom. It magnifies the rendered page without relaying it out: the layout viewport keeps its size and position, the visual viewport becomes a smaller window onto it, and the user can pan that window around.

IntersectionObserver computes the intersection between a target and its root. When root is null, the root intersection rectangle is the layout viewport of the top-level browsing context. Visual zoom does not change that rectangle, so:

  • Ratios do not change when the user zooms in or out.
  • No entries are delivered when the user pans while zoomed, because no intersection changed.
  • rootBounds in each entry still describes the unzoomed layout viewport.

Browser zoom (Ctrl/Cmd + plus) is different: it is layout zoom, which changes the CSS pixel size of the layout viewport, reflows the page and does deliver new entries. Only pinch-zoom is invisible to the observer.

Zoomed In, Still "Intersecting"A layout viewport containing a small visual viewport where the user has pinch-zoomed onto a paragraph. The paragraph is inside both rectangles. The sidebar ad is inside the layout viewport but outside the visual viewport, yet IntersectionObserver reports it as fully intersecting because it only knows about the layout viewport.paragraph — inside the zoomed viewsidebar ad — off-screen, ratio still 1.0Solid blue frame: layout viewport (IO root).The visual viewport covers only the top of the layout viewport; the observer cannot see the difference.

Comparison Table: Zoom Types and Observer Behaviour

Action Layout viewport Visual viewport New IO entries? visualViewport.scale
Pinch-zoom in (mobile) unchanged shrinks no > 1
Pan while zoomed unchanged moves no > 1
Browser zoom (Ctrl +) shrinks in CSS px shrinks with it yes, after reflow 1
Double-tap zoom unchanged shrinks no > 1
Rotate device resizes resizes yes may reset to 1

Minimal Reproducible Example

TypeScript
const ad = document.querySelector('#sidebar-ad')!;

new IntersectionObserver(([e]) => {
  console.log('ratio', e.intersectionRatio.toFixed(2), 'scale', visualViewport?.scale.toFixed(2));
}, { threshold: [0, 0.5, 1] }).observe(ad);

visualViewport?.addEventListener('resize', () => console.log('zoom now', visualViewport!.scale));

Pinch in on a phone until the ad is off-screen. The zoom now line prints repeatedly; the ratio line never does.

Production-Safe Solution

Keep the observer as the cheap first filter — an element outside the layout viewport is certainly not on screen — and refine with the visual viewport only while scale > 1.

TypeScript
interface Rect { top: number; left: number; bottom: number; right: number }

function visualRect(): Rect {
  const vv = window.visualViewport!;
  // visualViewport offsets are relative to the layout viewport, the same space
  // getBoundingClientRect() uses.
  return { top: vv.offsetTop, left: vv.offsetLeft,
           bottom: vv.offsetTop + vv.height, right: vv.offsetLeft + vv.width };
}

function visibleRatio(r: DOMRectReadOnly, v: Rect): number {
  const w = Math.max(0, Math.min(r.right, v.right) - Math.max(r.left, v.left));
  const h = Math.max(0, Math.min(r.bottom, v.bottom) - Math.max(r.top, v.top));
  const area = r.width * r.height;
  return area > 0 ? (w * h) / area : 0;
}

export function observeZoomAware(
  el: Element,
  onRatio: (ratio: number) => void,
): () => void {
  let inLayout = false;
  const vv = window.visualViewport;

  const recompute = () => {
    if (!inLayout) return onRatio(0);
    if (!vv || vv.scale <= 1.01) return;                // unzoomed: the observer is authoritative
    onRatio(visibleRatio(el.getBoundingClientRect(), visualRect()));
  };

  const io = new IntersectionObserver(([e]) => {
    inLayout = e.isIntersecting;
    if (!vv || vv.scale <= 1.01) onRatio(e.intersectionRatio);
    else recompute();
  }, { threshold: [0, 0.25, 0.5, 0.75, 1] });

  let frame = 0;
  const onViewport = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(recompute); };
  vv?.addEventListener('resize', onViewport);
  vv?.addEventListener('scroll', onViewport);
  io.observe(el);

  return () => {
    io.disconnect();
    vv?.removeEventListener('resize', onViewport);
    vv?.removeEventListener('scroll', onViewport);
    cancelAnimationFrame(frame);
  };
}

The getBoundingClientRect() call only happens while zoomed and the element is in the layout viewport, and only once per frame — a small cost confined to an uncommon state.

Two-Stage Zoom-Aware VisibilityFour boxes. IntersectionObserver filters elements that are in the layout viewport at all. A scale check decides whether the user is zoomed. When zoomed, a once-per-frame rectangle intersection with the visual viewport computes the true on-screen ratio. The result feeds the impression timer.IO filterin the layout viewport?scale > 1?only then refineRect intersectelement vsvisualViewport, onceper frameOn-screen ratiofeeds timers andmetrics

Edge Cases

Scrolled containers. If the element sits in a scrollable container and you observe with that container as root, the container's own clipping is already applied; you still need the visual viewport step for zoom, and you must intersect with both.

Desktop trackpad pinch. Chrome and Safari on macOS implement trackpad pinch as visual zoom with visualViewport.scale > 1, so the same code applies on laptops.

Accessibility. Users who rely on zoom are exactly the users most likely to be zoomed for long periods. Do not "fix" zoom by disabling it with user-scalable=no or maximum-scale=1; modern browsers ignore those values for accessibility reasons anyway.

Which metric is right? For viewability standards, check the standard's definition; many define the viewport as the browser window's visible area, which argues for the zoom-aware version. For lazy loading and media playback, the layout viewport is the better signal — a user zoomed into one corner is still likely to pan to the rest.

Deciding Which Features Need Zoom Awareness

Most observer-driven features should keep using the layout viewport. Zoom awareness costs a rectangle read per frame while zoomed and adds code paths that are hard to test, so apply it only where the metric's definition demands it.

Lazy loading and prefetching — keep the layout viewport. A user zoomed into one corner will pan to the rest soon, and having the images already loaded is the better experience.

Media autoplay and pausing — usually keep it. A video that is technically off-screen while zoomed but will be seen after a short pan should not restart from paused on every pan.

Viewability and impression metrics — use the zoom-aware ratio when the standard or contract defines visibility in terms of what is on screen. This is where auditors look.

Reading-time and attention analytics — use the zoom-aware ratio. Zoomed readers are focused on a small region, and crediting everything in the layout viewport overstates attention on the rest of the page.

Sticky UI and tooltips — do not use observers at all; position with visualViewport offsets directly, as covered in handling the mobile virtual keyboard.

Zoom Awareness by FeatureA grid of features against whether they should use the layout viewport or the zoom-aware visible ratio. Lazy loading and media playback use the layout viewport. Viewability metrics and attention analytics use the zoom-aware ratio. Sticky interface elements use visualViewport offsets directly rather than any observer.Signal to useWhyLazy loadinglayout viewportuser will pan soonMedia playbacklayout viewportavoid restart on panViewability metricszoom-aware ratiocontract says on screenAttention analyticszoom-aware ratiozoom means focusSticky UIvisualViewport offsetspositioning, not visibility

Verification Steps

  • Zoom to 300% on a phone and pan the ad in and out of view; the zoom-aware ratio should move between 0 and 1 while the raw observer ratio stays fixed.
  • Reset zoom and confirm the callback reverts to the observer's own intersectionRatio, with no rect reads in the Performance panel.
  • Use browser zoom on desktop and confirm the observer alone reports the change, as expected for layout zoom.
  • Record a trace while panning zoomed and confirm there is at most one layout read per frame.

Common Mistakes to Avoid

  • Assuming all zoom is invisible to the observer. Browser (layout) zoom reflows and delivers entries; only pinch-zoom does not.
  • Mixing coordinate spaces. pageTop is document-relative; getBoundingClientRect() is layout-viewport-relative. Use offsetTop/offsetLeft with bounding rects.
  • Polling during zoom. The visualViewport events already tell you when the visible rectangle changes.
  • Disabling zoom to make the numbers match. It harms users and is ignored by most browsers.

FAQ

Why doesn't IntersectionObserver account for pinch-zoom?

Because the specification defines the implicit root as the layout viewport, and pinch-zoom deliberately does not change it. That keeps observer results stable while the user magnifies content, which is the right default for loading and playback decisions.

Does rootBounds change when the user zooms?

No. rootBounds reflects the root intersection rectangle, which for the implicit root is the unzoomed layout viewport.

Can I use the visual viewport as the observer's root?

No. The root must be an element or a document. The visual viewport is exposed only as the visualViewport object, so zoom-aware visibility has to be computed in script, as shown above.

Is getBoundingClientRect affected by pinch-zoom?

No. It returns coordinates relative to the layout viewport, in CSS pixels, regardless of visual zoom — which is exactly why it can be compared with the visualViewport offsets.


↑ Back to Visual Viewport API & Mobile Viewport Units