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: fixed elements attach to and that vw/vh historically referred to. It changes size when the address bar collapses or expands on some browsers, and — depending on the browser and the interactive-widget setting — 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.visualViewport object exposes its size, offset and scale, and fires resize and scroll events.

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".

Three Rectangles Called "the Viewport"Three stacked layers. The layout viewport is what fixed positioning and the implicit IntersectionObserver root use; it changes with the address bar. The visual viewport is what the user actually sees; it shrinks with pinch zoom and the keyboard and is exposed through window.visualViewport. The viewport units svh, lvh and dvh describe the layout viewport in its smallest, largest and current states.Layout viewportFixed positioning and the implicit IntersectionObserver root attach here. It resizesas the address bar collapses.Visual viewportWhat is on screen right now. Shrinks under pinch zoom and, often, the keyboard. Readvia window.visualViewport.svh / lvh / dvhCSS lengths for the small, large and dynamic states of the layout viewport; no JSrequired.

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

TypeScript
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

TypeScript
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

TypeScript
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

CSS
.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; }
}

From visualViewport Events to LayoutFour boxes left to right. visualViewport resize and scroll events fire at high frequency. A requestAnimationFrame gate coalesces them to one read per frame. The read writes CSS custom properties on the root element. Stylesheets consume the properties, so components reposition without their own listeners.resize / scrollmany events per pan orkeyboard animationrAF gateone read per frameCustom properties--vv-height,--keyboard-insetCSS consumersno per-componentlisteners

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

What Each Mechanism TracksA grid of mechanisms against three mobile events. The svh unit tracks none of them. The dvh unit tracks the address bar only. The lvh unit tracks none. The visualViewport object tracks the address bar, the keyboard and pinch zoom. The implicit IntersectionObserver root tracks the address bar only, never the keyboard overlay or zoom.Address bar movesKeyboard opensPinch zoom100svhfixed smallnono100dvhtracksonly with resizes-contentno100lvhfixed largenonovisualViewporttrackstrackstracksIO implicit roottracksonly with resizes-contentignores zoom

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.

Which Signal Fires for Each Mobile EventA grid of mobile events against three signals. Page load fires ResizeObserver and IntersectionObserver but not visualViewport. The toolbar collapse fires all three. Opening the keyboard fires only visualViewport under the default setting. Pinch zoom fires only visualViewport. Rotation fires all three.ResizeObserverIntersectionObservervisualViewport eventsPage loadinitial entriesinitial entriesnoneToolbar collapsesper frame on dvh boxesphantom crossingsresizeKeyboard opensnone by defaultnone by defaultresize and scrollPinch zoomnonenoneresize and scrollRotationburstburstresize

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.

TypeScript
// 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.

TypeScript
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.height and visualViewport.offsetTop
  • Check the viewport meta tag for interactive-widget
  • Search stylesheets for 100vh and decide per rule whether it meant svh, lvh or dvh
  • Count ResizeObserver deliveries while scrolling down a page with a collapsing URL bar; a storm points at dvh
TypeScript
// 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.


↑ Back to Core Observer Fundamentals & Browser APIs