When the on-screen keyboard opens, most mobile browsers shrink only the visual viewport, so the reliable way to keep an element above the keyboard is to read window.visualViewport and offset the element by the difference between the layout and visual heights.

Problem / Scenario Context

A support chat widget has its composer fixed to the bottom of the screen with position: fixed; bottom: 0. On desktop and in DevTools device mode it works. On an iPhone, tapping the input brings up the keyboard and the composer vanishes behind it; the user types blind until they dismiss the keyboard. On Android Chrome the same widget behaves differently depending on the version — sometimes the composer rides up with the keyboard, sometimes it does not.

The inconsistency comes from the three-viewport model described in Visual Viewport API & Mobile Viewport Units. Fixed positioning attaches to the layout viewport, and the keyboard usually does not resize it.

Mechanics Explanation

Under the default interactive-widget=resizes-visual behaviour, opening the keyboard:

  1. Leaves the layout viewport unchanged. window.innerHeight (in most engines), documentElement.clientHeight and the dvh unit all keep their previous values.
  2. Shrinks the visual viewport by the keyboard's height. visualViewport.height drops, and the browser may scroll the visual viewport down (offsetTop increases) so the focused input stays visible.
  3. Leaves fixed elements where they were — at the bottom of the layout viewport, which is now underneath the keyboard.

So the keyboard's height, from the page's point of view, is layoutHeight − visualViewport.height − visualViewport.offsetTop. The offsetTop term matters: when Safari scrolls the visual viewport to reveal the input, the visible area's bottom edge moves up by less than the full keyboard height.

Where the Keyboard Leaves a Fixed ComposerA layout viewport with the visual viewport shrunk at the top by the keyboard. The composer fixed to the bottom of the layout viewport sits inside the keyboard area and is hidden. Offsetting it by the keyboard inset moves it just above the keyboard where the user can see it.visual viewport — what the user can seecomposer, bottom: var(--keyboard-inset)composer at bottom: 0 — under the keyboardSolid blue frame: layout viewport, unchanged.The keyboard occupies the lower part of the layout viewport without resizing it; only visualViewport reports the change.

Comparison Table: What Changes When the Keyboard Opens

Measure iOS Safari (default) Chrome Android, resizes-visual Chrome Android, resizes-content
window.innerHeight unchanged unchanged shrinks
100dvh unchanged unchanged shrinks
visualViewport.height shrinks shrinks shrinks
visualViewport.offsetTop may increase usually 0 0
Fixed bottom: 0 element hidden hidden above keyboard
ResizeObserver on <body> no entry no entry entry

Minimal Reproducible Example

TypeScript
// Log what each measure does as the keyboard animates in.
const input = document.querySelector<HTMLInputElement>('#msg')!;
const vv = window.visualViewport!;

function log(label: string): void {
  console.log(label, {
    inner: innerHeight,
    client: document.documentElement.clientHeight,
    vvH: Math.round(vv.height),
    vvTop: Math.round(vv.offsetTop),
  });
}
input.addEventListener('focus', () => log('focus'));
vv.addEventListener('resize', () => log('vv resize'));

On iOS the inner and client values stay constant while vvH falls by around 300 px and vvTop moves as Safari pans the page.

Production-Safe Solution

Publish the keyboard inset as a custom property, coalesced to one write per frame, and let the composer's CSS consume it. Ignore the inset while pinch-zoomed, where a small visual viewport means zoom, not a keyboard.

TypeScript
interface KeyboardTracker { stop: () => void }

export function trackKeyboardInset(target: HTMLElement = document.documentElement): KeyboardTracker {
  const vv = window.visualViewport;
  if (!vv) return { stop: () => {} };                 // no API: leave CSS fallback in place

  let frame = 0;
  const update = (): void => {
    cancelAnimationFrame(frame);
    frame = requestAnimationFrame(() => {
      const layoutH = document.documentElement.clientHeight;
      // Only a keyboard when not zoomed; under zoom the visual viewport is small by design.
      const inset = vv.scale > 1.01 ? 0 : Math.max(0, layoutH - vv.height - vv.offsetTop);
      target.style.setProperty('--keyboard-inset', `${Math.round(inset)}px`);
    });
  };

  const ac = new AbortController();
  vv.addEventListener('resize', update, { signal: ac.signal });
  vv.addEventListener('scroll', update, { signal: ac.signal });
  update();
  return { stop: () => { ac.abort(); cancelAnimationFrame(frame); } };
}
CSS
.composer {
  position: fixed;
  inset-inline: 0;
  bottom: var(--keyboard-inset, 0px);
  /* Match the keyboard's own animation instead of snapping. */
  transition: bottom 120ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
  .composer { transition: none; }
}

Where the design allows it, opting into resizes-content is simpler still — the layout viewport then shrinks, fixed elements move with it and dvh follows:

HTML
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">

The catch is that it relayouts the whole page when the keyboard opens, and every dvh-sized element observed by a ResizeObserver delivers an entry. On a long page, the visualViewport approach touches one element instead of the whole layout.

Two Ways to Keep the Composer VisibleTwo columns. Measuring visualViewport changes only one custom property, works on every current engine including iOS Safari, and costs one style write per frame. Opting into resizes-content needs no script, but relayouts the whole page when the keyboard opens and is not honoured by every engine.Measure visualViewportOne custom property changes; nothing else relayoutsWorks on iOS Safari, where the keyboard overlaysNeeds a script and a zoom guardinteractive-widget=resizes-contentNo script: fixed elements and dvh follow the keyboardRelayouts the page and fires ResizeObserver widelyNot honoured by every engine, so still needs afallback

Edge Cases

The keyboard animates. visualViewport fires several resize events during the slide-in; the rAF coalescing above makes each frame one write, and the short CSS transition smooths any gaps. Do not debounce with a long timeout, or the composer arrives after the keyboard has finished.

Focus scroll fights your offset. When the focused input is the composer, Safari may scroll the visual viewport so the input is visible, which changes offsetTop. Because the formula subtracts offsetTop, the composer ends up at the visual viewport's bottom edge either way — just make sure you listen to scroll as well as resize.

Hardware keyboards and floating keyboards. iPad with a hardware keyboard shows only a small shortcut bar; split and floating keyboards on tablets do not cover the bottom edge at all. The formula handles both because it measures what is actually visible rather than assuming a keyboard height.

Scroll locking. Chat widgets often lock body scroll while open. If the lock uses position: fixed on <body>, the visual viewport can no longer pan and Safari will resize differently; prefer overflow: hidden on the scroll container.

Coordinating With Scroll Containers and Focus

The composer is rarely alone. A chat view has a message list above it that should stay pinned to its newest message when the keyboard opens, and a form has a submit button that should remain reachable. Both depend on the same inset.

For the message list, the list's scroll container shrinks by the inset (give it padding-block-end: var(--keyboard-inset) or size it with --vv-height), and the newest message must stay in view. A ResizeObserver on the scroll container is the right trigger: it fires in the rendering steps after the padding change is laid out, so adjusting scrollTop there lands in the same frame and the list never visibly jumps.

TypeScript
export function pinToBottom(list: HTMLElement): () => void {
  let atBottom = true;
  list.addEventListener('scroll', () => {
    atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 4;
  }, { passive: true });
  const ro = new ResizeObserver(() => {
    if (atBottom) list.scrollTop = list.scrollHeight;   // same frame as the resize
  });
  ro.observe(list);
  return () => ro.disconnect();
}

Only re-pin when the user was already at the bottom; someone scrolled up reading history should not be yanked down because the keyboard opened.

Keyboard Opens in a Chat ViewFour steps. The visualViewport resize event fires as the keyboard slides in. The tracker publishes the keyboard inset custom property. The composer moves up and the message list's bottom padding grows. A ResizeObserver on the list re-pins it to the newest message in the same frame, but only if the user was already at the bottom.1visualViewport resizeFires several times while the keyboard slides in.2Publish the insetOne custom property write per frame via the rAF gate.3Composer and list movebottom and padding-block-end both read the same property.4Re-pin the listResizeObserver restores scrollTop in the same frame, if the user was at the bottom.

Verification Steps

  • Remote-debug a real iPhone (Safari Web Inspector) and watch --keyboard-inset on the root element change as the keyboard opens and closes.
  • Pinch-zoom with the keyboard closed and confirm the inset stays at 0px.
  • Rotate the device with the keyboard open; the composer should re-seat above the keyboard within a frame or two.
  • Test on Chrome Android with and without interactive-widget to confirm both paths leave the composer visible.
  • Check with a screen reader that the composer remains in the reading order and focusable after it moves.

Common Mistakes to Avoid

  • Using window.innerHeight as the visible height. It does not change for the keyboard on the platforms where you most need it to.
  • Hard-coding a keyboard height. Keyboards differ by device, language, predictive bar and accessibility text size.
  • Listening only to resize. Visual viewport panning arrives as scroll, and ignoring it misplaces the composer whenever Safari scrolls to the input.
  • Forgetting teardown. A widget that mounts and unmounts should abort its listeners; otherwise every open leaks another pair.

FAQ

Does the VirtualKeyboard API replace visualViewport?

It complements it on engines that support it. With navigator.virtualKeyboard.overlaysContent set to true, the keyboard never resizes anything and CSS gets keyboard-inset environment variables. It is not available in Safari, so visualViewport remains the portable baseline.

Why not use a ResizeObserver on the body?

Because under the default keyboard behaviour the body does not change size. ResizeObserver only helps when interactive-widget is resizes-content, and in that case fixed elements already move without help.

Is it safe to read visualViewport during server-side rendering?

No — there is no window on the server. Run the tracker only after mount, and let the CSS fallback of 0px apply until then, so the server and first client render match.

What about bottom sheets that are taller than the remaining space?

Cap their height with the visual viewport height as well: publish --vv-height alongside the inset and set max-block-size to it, so the sheet scrolls internally instead of extending under the keyboard.


↑ Back to Visual Viewport API & Mobile Viewport Units