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:
- Leaves the layout viewport unchanged.
window.innerHeight(in most engines),documentElement.clientHeightand thedvhunit all keep their previous values. - Shrinks the visual viewport by the keyboard's height.
visualViewport.heightdrops, and the browser may scroll the visual viewport down (offsetTopincreases) so the focused input stays visible. - 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.
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
// 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.
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); } };
}
.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:
<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.
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.
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.
Verification Steps
- Remote-debug a real iPhone (Safari Web Inspector) and watch
--keyboard-inseton 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-widgetto 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.innerHeightas 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 asscroll, 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.
Related
- Visual Viewport API & Mobile Viewport Units — the three-viewport model
- Tracking the Address Bar Collapse on Mobile Safari — the other thing that moves the bottom edge
- Keeping Keyboard Focus Stable When Content Loads — focus management around moving UI