On a phone, "the viewport" is not one rectangle but three: the layout viewport that CSS positions against, the visual viewport the user actually sees, and the family of viewport units that try to describe both. Observer code written on a desktop quietly assumes they are the same, and breaks the first time a keyboard opens.
Concept Framing
Every observer on this site measures geometry relative to something. An IntersectionObserver with no root measures against the top-level document's viewport; a ResizeObserver measures an element's own box. On desktop browsers the viewport is simply the window's content area and nothing moves it except resizing the window. The Core Observer Fundamentals section treats that case; this topic covers what changes on mobile.
Mobile browsers introduced a split between two viewports:
- The layout viewport is the rectangle that
position: fixedelements attach to and thatvw/vhhistorically referred to. It changes size when the address bar collapses or expands on some browsers, and — depending on the browser and theinteractive-widgetsetting — when the on-screen keyboard opens. - The visual viewport is the part of the page currently visible on screen. It shrinks when the user pinch-zooms or when the keyboard overlays the page, and it can pan around inside the layout viewport. The
window.visualViewportobject exposes its size, offset and scale, and firesresizeandscrollevents.
CSS then added three families of viewport units to describe the moving address bar: svh (small — as if all browser UI is expanded), lvh (large — as if all browser UI is retracted) and dvh (dynamic — whatever the current state is). Each has width and inline/block equivalents.
The observer consequence is subtle. The implicit root of an IntersectionObserver is the layout viewport, not the visual one. A pinch-zoomed user can be looking at a small corner of the page while the observer reports that elements far outside that corner are fully "in view".
Spec / Signature Reference Table
The VisualViewport interface is small, and most bugs come from confusing its offsets with the page scroll position.
| Member | Type | Meaning |
|---|---|---|
width, height |
number (CSS px) | size of the visible area, after pinch-zoom scaling |
offsetLeft, offsetTop |
number | position of the visual viewport inside the layout viewport |
pageLeft, pageTop |
number | position of the visual viewport relative to the document |
scale |
number | pinch-zoom factor; 1 when not zoomed |
onresize / resize event |
event | size or scale changed (keyboard, zoom, rotation) |
onscroll / scroll event |
event | visual viewport panned inside the layout viewport |
onscrollend / scrollend event |
event | panning finished (newer engines) |
Alongside it, the viewport meta tag's interactive-widget key controls how the keyboard affects the viewports:
interactive-widget value |
Keyboard resizes the layout viewport? | Keyboard resizes the visual viewport? | Units follow keyboard? |
|---|---|---|---|
resizes-visual (default in most engines) |
no | yes | no |
resizes-content |
yes | yes | yes, dvh shrinks |
overlays-content |
no | no | no — use the VirtualKeyboard API |
Step-by-Step Implementation
The building block for most visual-viewport work is a small, observable store that turns the visualViewport events into CSS custom properties, so layout can react without JavaScript touching every component.
Step 1: Feature-detect and read the current state
interface ViewportState {
width: number; height: number;
offsetTop: number; offsetLeft: number;
scale: number;
keyboardInset: number; // layout height minus visual height, when not zoomed
}
function readViewport(): ViewportState | null {
const vv = window.visualViewport;
if (!vv) return null; // very old engines
const layoutHeight = document.documentElement.clientHeight;
return {
width: vv.width, height: vv.height,
offsetTop: vv.offsetTop, offsetLeft: vv.offsetLeft,
scale: vv.scale,
keyboardInset: vv.scale === 1 ? Math.max(0, layoutHeight - vv.height - vv.offsetTop) : 0,
};
}
keyboardInset is only meaningful when the page is not zoomed; under zoom the visual viewport is small for a different reason.
Step 2: Publish it as custom properties once per frame
function publish(state: ViewportState): void {
const root = document.documentElement.style;
root.setProperty('--vv-height', `${state.height}px`);
root.setProperty('--vv-offset-top', `${state.offsetTop}px`);
root.setProperty('--keyboard-inset', `${state.keyboardInset}px`);
}
let pending = false;
function schedule(): void {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
const s = readViewport();
if (s) publish(s);
});
}
visualViewport fires scroll at high frequency during a pan; coalescing to one write per frame keeps the cost flat.
Step 3: Subscribe and return a teardown
export function trackVisualViewport(): () => void {
const vv = window.visualViewport;
if (!vv) return () => {};
const ac = new AbortController();
vv.addEventListener('resize', schedule, { signal: ac.signal });
vv.addEventListener('scroll', schedule, { signal: ac.signal });
schedule();
return () => ac.abort();
}
Step 4: Let CSS consume the values
.chat-composer {
position: fixed;
inset-inline: 0;
/* Sit on top of the keyboard, falling back to the bottom edge. */
bottom: var(--keyboard-inset, 0px);
}
.full-height-panel {
block-size: 100svh; /* safe minimum, no JS needed */
}
@supports (height: 100dvh) {
.full-height-panel { block-size: 100dvh; }
}
Threshold / Configuration Variants
How you size and position things depends on which rectangle the design is really tied to.
| Goal | Use | Why |
|---|---|---|
| Hero that fills the first screen without jumping | 100svh |
smallest state; never overflows, never resizes on scroll |
| App shell that always exactly fills the screen | 100dvh |
tracks the address bar; resizes during scroll |
| Background that never shows a gap | 100lvh |
largest state; may be partly hidden under browser UI |
| Composer above the keyboard | --keyboard-inset from visualViewport |
units ignore the keyboard under the default setting |
| Tooltip that stays visible while pinch-zoomed | visualViewport.offsetTop/Left |
layout viewport and units ignore zoom |
| Lazy loading, analytics | IntersectionObserver, implicit root | zoom rarely matters; layout viewport is correct |
Edge Cases & Gotchas
Safari's keyboard does not resize anything by default. iOS Safari overlays the keyboard on the page and scrolls the visual viewport to keep the focused input visible. window.innerHeight does not change, dvh does not change, and fixed elements at bottom: 0 end up behind the keyboard. Only visualViewport.height and offsetTop reveal what happened. The virtual keyboard guide covers the full pattern.
Address-bar collapse fires resize storms. On Chrome for Android, scrolling down collapses the URL bar, which resizes the layout viewport and every element sized in dvh. Each of those elements that is observed by a ResizeObserver delivers an entry — sometimes every frame of the collapse animation. Use svh for anything whose size should not follow the bar.
Pinch zoom does not change IntersectionObserver results. The observer's implicit root is the layout viewport, and pinch zoom only changes the visual viewport. Elements outside the zoomed-in area are still reported as intersecting. For zoom-aware visibility, compare entry.boundingClientRect with the visualViewport rectangle yourself; see pinch-zoom and IntersectionObserver.
position: fixed is relative to the layout viewport. When the visual viewport is smaller and panned, a fixed header can scroll off-screen. That is correct per spec but surprises users of zoom; the fix is to offset by visualViewport.offsetTop only when scale > 1.
Rotation resets everything. Orientation change resizes both viewports, may reset zoom, and triggers ResizeObserver entries across the page. Treat it as a full layout change, not an incremental one.
How Observers Behave Across Common Mobile Events
It helps to walk through a real session and note which observers fire and why, because the combinations are what produce surprising bug reports.
Page load. Every observed element delivers its initial entry: IntersectionObserver reports current visibility against the layout viewport in the small state (toolbar expanded), and ResizeObserver reports initial sizes. Anything sized in dvh reports the small-state height.
First scroll down. On Chrome for Android and iOS Safari the toolbar retracts over a few hundred milliseconds. The layout viewport grows, dvh elements grow with it, and each observed dvh element delivers a resize entry per frame of the animation. The implicit root of every IntersectionObserver grows too, so elements near the bottom edge may cross thresholds without the user having scrolled them — a "phantom" crossing caused purely by browser chrome. Lazy loaders tolerate this; impression counters should be aware of it.
Tap into a text field. Under the default behaviour nothing in the layout viewport changes. visualViewport fires resize as the keyboard slides in, and possibly scroll as Safari pans to reveal the field. No observer callback runs at all, which is why keyboard handling cannot be built on observers.
Pinch-zoom to read small text. Only visualViewport fires. Observers are silent because neither the layout viewport nor any element box changed.
Rotate to landscape. Everything changes: both viewports, every unit, many element sizes. Expect a burst of resize entries and intersection entries in the frames after rotation, and treat the burst as a fresh layout rather than a sequence of incremental changes.
The practical rule that falls out of this walk-through: observers describe the layout, visualViewport describes the screen. Use observers for decisions about content and loading, and visualViewport for anything that must stay physically visible to the user.
Framework Integration Patterns
A viewport store is global state, so every framework version should share one subscription rather than attach a listener per component.
// React: one subscription, many readers, via useSyncExternalStore.
import { useSyncExternalStore } from 'react';
let snapshot = { height: 0, keyboardInset: 0 };
const listeners = new Set<() => void>();
function subscribe(fn: () => void): () => void {
listeners.add(fn);
if (listeners.size === 1) window.visualViewport?.addEventListener('resize', emit);
return () => {
listeners.delete(fn);
if (listeners.size === 0) window.visualViewport?.removeEventListener('resize', emit);
};
}
function emit(): void {
const vv = window.visualViewport!;
const inset = Math.max(0, document.documentElement.clientHeight - vv.height - vv.offsetTop);
snapshot = { height: vv.height, keyboardInset: vv.scale === 1 ? inset : 0 };
listeners.forEach((l) => l());
}
export function useVisualViewport() {
return useSyncExternalStore(subscribe, () => snapshot, () => snapshot);
}
The server snapshot returns zeros, which keeps hydration stable — the SSR concern covered in the SSR hydration topic. In Vue, the same store becomes a module-level shallowRef updated by one listener; in Angular, a root-provided service exposing a signal.
Testing Viewport Behaviour in CI
Real devices are the ground truth, but a surprising amount can be pinned down in automated tests. Playwright's mobile device descriptors set a mobile viewport and touch, and you can simulate the visual viewport shrinking by changing the viewport size, which exercises the same code paths as a layout-resizing keyboard.
import { test, expect, devices } from '@playwright/test';
test.use({ ...devices['Pixel 7'] });
test('composer stays above a shrinking viewport', async ({ page }) => {
await page.goto('/chat');
const before = await page.locator('.composer').boundingBox();
// Simulate resizes-content behaviour: the viewport loses 300px at the bottom.
const vp = page.viewportSize()!;
await page.setViewportSize({ width: vp.width, height: vp.height - 300 });
await page.waitForFunction(() => new Promise((r) => requestAnimationFrame(() => r(true))));
const after = await page.locator('.composer').boundingBox();
expect(after!.y + after!.height).toBeLessThanOrEqual(vp.height - 300 + 1);
expect(after!.y).toBeLessThan(before!.y);
});
What automation cannot reproduce is the overlay behaviour of iOS Safari, pinch-zoom and the address-bar animation. Keep a short manual checklist for those, run on a real iPhone and a real Android phone before each release that touches fixed or full-height UI. The broader approach to observer testing is covered in testing observers in JSDOM and real browsers.
Debugging Checklist
- Log
innerHeight,documentElement.clientHeight,visualViewport.heightandvisualViewport.offsetTop - Check the viewport meta tag for
interactive-widget - Search stylesheets for
100vhand decide per rule whether it meantsvh,lvhordvh - Count
ResizeObserverdeliveries while scrolling down a page with a collapsing URL bar; a storm points atdvh
// Paste on a device: prints every viewport measure on each change.
const vv = visualViewport!;
const dump = () => console.table({
innerHeight, clientHeight: document.documentElement.clientHeight,
vvHeight: vv.height, vvOffsetTop: vv.offsetTop, scale: vv.scale,
});
vv.addEventListener('resize', dump); vv.addEventListener('scroll', dump); dump();
FAQ
What is the difference between dvh and the visual viewport height?
dvh describes the layout viewport in its current address-bar state. The visual viewport height additionally shrinks for pinch zoom and, under the default keyboard behaviour, for the on-screen keyboard. dvh never reflects zoom.
Should I replace every 100vh with 100dvh?
No. dvh changes while the user scrolls on browsers with a collapsing address bar, which resizes the element and everything laid out after it. Use svh for anything that should stay stable, and dvh only for shells that must exactly fill the current screen.
Does IntersectionObserver know about the keyboard?
Only if the keyboard resizes the layout viewport, which happens with interactive-widget set to resizes-content or on engines that default to it. Under the common default, the keyboard overlays the page and the observer's root does not change.
Is window.visualViewport safe to use everywhere?
It is supported in all current engines. Feature-detect it anyway and fall back to innerHeight, because some embedded webviews and very old browsers lack it, and because server-side rendering has no window at all.
How do I observe the visual viewport with ResizeObserver?
You cannot; it is not an element. Listen for the visualViewport resize event instead, or publish its size into a custom property and observe an element sized by that property if other code already relies on ResizeObserver.
Related
- Handling the Mobile Virtual Keyboard with visualViewport — keeping inputs and composers visible
- dvh, svh, lvh vs ResizeObserver for Full-Height Layouts — CSS first, script second
- Pinch-Zoom and IntersectionObserver: What Changes — zoom-aware visibility
- Tracking the Address Bar Collapse on Mobile Safari — measuring browser chrome
- ResizeObserver Mechanics & Triggers — what a viewport change does to observed elements