CSS dvh (dynamic viewport height) was introduced to replace the infamous 100vh bug on mobile. It handles the browser URL bar appearing and disappearing — but it completely ignores the on-screen keyboard.
When the keyboard opens, position: fixed elements get covered. dvh won't help you here.
Why dvh ignores the keyboard
The CSS viewport units spec treats the virtual keyboard as an overlay — it doesn't resize the layout viewport. So dvh, svh, and 100vh all stay the same value when the keyboard opens.
The real fix: Visual Viewport API
keyboardHeight = window.innerHeight - window.visualViewport.height
When the keyboard opens, visualViewport.height shrinks while window.innerHeight stays fixed. The difference is the keyboard height.
But there's a catch — iOS Safari and Android Chrome behave differently.
iOS Safari
When the keyboard opens, iOS scrolls the visual viewport upward. visualViewport.offsetTop increases. It fires both resize and scroll events on visualViewport — but window resize does not fire.
Android Chrome
Android shrinks the layout viewport itself. window.innerHeight decreases and window.resize fires. visualViewport.offsetTop stays 0.
If you only handle one case, the other breaks.
The guard logic
const handleWindowResize = () => {
const vv = window.visualViewport
const currWidth = window.innerWidth
const widthChanged = currWidth !== layoutWidthRef.current
// iOS: vv.offsetTop > 0 when keyboard is open → skip
// Android: width unchanged when keyboard opens → skip
// True orientation/resize: width changes → update reference
if (!vv || (vv.offsetTop === 0 && widthChanged)) {
layoutHeightRef.current = window.innerHeight
layoutWidthRef.current = currWidth
}
}
In testing, keyboard opens didn't affect innerWidth, while orientation changes did. This is the heuristic that makes the width guard reliable for Android.
The hook
I wrapped this into use-dynamic-viewport, which injects two CSS variables automatically:
npm install use-dynamic-viewport
import { useDynamicViewport } from 'use-dynamic-viewport'
export function Layout() {
useDynamicViewport() // injects --dvh and --keyboard-height on :root
return <div className="app">...</div>
}
/* App height tracks the visible area */
.app {
height: var(--dvh, 100svh);
}
/* Fixed bottom bar stays above the keyboard */
.input-bar {
position: fixed;
bottom: var(--keyboard-height, 0px);
left: 0;
right: 0;
}
You can also read the values in JS:
const { viewportHeight, keyboardHeight, isKeyboardOpen } = useDynamicViewport()
Details
- iOS Safari ✅ Android Chrome ✅
- Next.js App Router / SSR safe ✅
- Zero dependencies, ~0.8KB gzipped
- React 17+, TypeScript
- 19 tests (Vitest + React Testing Library)
GitHub: https://github.com/rl0425/use-dynamic-viewport
npm: https://www.npmjs.com/package/use-dynamic-viewport
Top comments (0)