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.
rootBoundsin 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.
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
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.
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.
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.
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.
pageTopis document-relative;getBoundingClientRect()is layout-viewport-relative. UseoffsetTop/offsetLeftwith 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.
Related
- Handling the Mobile Virtual Keyboard with visualViewport — the same API, a different trigger
- IntersectionObserver v2: Tracking Visibility Support — occlusion-aware visibility
- isIntersecting vs intersectionRatio: Which to Check — reading entries correctly