use-dynamic-viewport
v0.1.0
Published
React hook that injects --dvh and --keyboard-height CSS variables using the Visual Viewport API. Fixes the gap CSS dvh leaves when the mobile keyboard opens.
Maintainers
Readme
use-dynamic-viewport
React hook that injects
--dvhand--keyboard-heightCSS variables using the Visual Viewport API — fixing the gap CSSdvhleaves when the mobile keyboard opens.
The problem
CSS dvh (dynamic viewport height) was introduced to replace the infamous 100vh bug on mobile. It responds to the browser URL bar appearing and disappearing — but it does not update when the on-screen keyboard opens.
/* This still breaks when the keyboard opens */
.chat-input-bar {
position: fixed;
bottom: 0;
height: 60px;
}When the keyboard appears, position: fixed elements get covered. dvh won't help you here.
The reliable fix requires window.visualViewport:
keyboardHeight = layoutHeight - window.visualViewport.heightThe hook captures window.innerHeight at mount time as a stable reference before any keyboard interaction. When the keyboard opens, window.visualViewport.height shrinks — the difference between the captured reference and the current vv.height is the keyboard height.
Note: the hook applies two guards to protect the baseline from being overwritten by a keyboard event:
- iOS Safari — when the keyboard opens,
visualViewport.offsetTopincreases (the visual viewport scrolls). The hook skips the update whenoffsetTop > 0.- Android Chrome — when the keyboard opens, the layout viewport shrinks but
offsetTopstays0. The hook skips the update wheninnerWidthis unchanged, because the keyboard never changes the viewport width (only orientation changes do).
This hook wraps that logic and injects two CSS variables automatically.
Installation
npm install use-dynamic-viewportRequires React 17 or later. Zero runtime dependencies.
Quick start
import { useDynamicViewport } from 'use-dynamic-viewport'
export function Layout() {
useDynamicViewport() // injects --dvh and --keyboard-height
return <div className="app">{/* ... */}</div>
}/* Use --dvh instead of 100vh */
.fullscreen {
height: var(--dvh, 100vh);
}
/* Fixed bottom bar that stays above the keyboard */
.chat-input {
position: fixed;
bottom: var(--keyboard-height, 0px); /* moves entire element above the keyboard */
left: 0;
right: 0;
}API
useDynamicViewport(options?)
const { viewportHeight, keyboardHeight, isKeyboardOpen } = useDynamicViewport(options?)Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| heightVar | string | '--dvh' | CSS custom property name for the visual viewport height |
| keyboardVar | string | '--keyboard-height' | CSS custom property name for the keyboard height |
| enabled | boolean | true | Set to false to disable the hook entirely |
Return value
| Property | Type | Description |
|----------|------|-------------|
| viewportHeight | number | Current visual viewport height in pixels |
| keyboardHeight | number | Current keyboard height in pixels (0 when closed) |
| isKeyboardOpen | boolean | true when the on-screen keyboard is open |
Examples
Basic — just inject the CSS variables
useDynamicViewport()Read values in JavaScript
const { viewportHeight, keyboardHeight, isKeyboardOpen } = useDynamicViewport()
return (
<div>
{isKeyboardOpen && <p>Keyboard is open ({keyboardHeight}px)</p>}
</div>
)Custom CSS variable names
useDynamicViewport({
heightVar: '--vh',
keyboardVar: '--kb-height',
}).page { height: var(--vh); }
.footer { padding-bottom: var(--kb-height); }Conditional activation
const isMobile = useMediaQuery('(max-width: 768px)')
useDynamicViewport({ enabled: isMobile })How it works
CSS injected on <html>
:root {
--dvh: 780px; /* actual visible height (URL bar + keyboard aware) */
--keyboard-height: 0px; /* 0 when closed, ~300px when keyboard is open */
}Variables are updated on every visualViewport resize and scroll event (both are needed for iOS Safari compatibility), throttled with requestAnimationFrame.
Cleanup
Variables are removed from document.documentElement when the component unmounts. Event listeners are also cleaned up.
SSR safety
window access is guarded by typeof window === 'undefined' — safe for Next.js App Router, Remix, and any SSR environment.
Comparison
| Feature | use-dynamic-viewport | Manual visualViewport | viewportify |
|---------|:--------------------:|:---------------------:|:-----------:|
| CSS --dvh variable | ✅ | ❌ manual | ✅ |
| CSS --keyboard-height variable | ✅ | ❌ manual | ✅ |
| React hook (first-class) | ✅ | ❌ | ⚠️ wrapper |
| Next.js App Router / SSR safe | ✅ | ❌ | ⚠️ |
| iOS Safari scroll event compat | ✅ | ⚠️ easy to miss | ✅ |
| Zero dependencies | ✅ | ✅ | ✅ |
| Bundle size | ~0.8KB | n/a | larger |
| Cleanup on unmount | ✅ | ❌ manual | ❌ |
Known limitations
- Keyboard height detection uses the
window.innerHeightcaptured at mount time as a baseline. Whenwindow.visualViewport.heightshrinks below that baseline, the difference is treated as keyboard height. Two device behaviors are handled:- iOS Safari — visual viewport scrolls when keyboard opens (
vv.offsetTop > 0). The hook detects this and preserves the baseline. - Android Chrome — layout viewport itself shrinks (
window.innerHeightdecreases,vv.offsetTopstays0). The hook detects this via a width guard: keyboard open never changesinnerWidth, so the baseline is only updated wheninnerWidthchanges (true orientation change or window resize). - Niche Android browsers that behave differently from these two patterns may not be fully supported.
- iOS Safari — visual viewport scrolls when keyboard opens (
- On desktop,
--keyboard-heightis0pxin normal use. If the browser window height is resized without changing its width (e.g. dragging the bottom edge), the variable may briefly show a non-zero value. This has no practical impact since desktop browsers have no on-screen keyboard. - The hook injects variables on
document.documentElement. If you render multiple instances, the last mounted instance controls the variables. - Pinch-to-zoom shrinks
visualViewport.height, which can produce a false positive--keyboard-height. Most mobile apps disable zoom via<meta name="viewport" content="..., maximum-scale=1">, which avoids this. - Dynamic CSS variable names: if you change
heightVarorkeyboardVarat runtime, the old variable name is not automatically removed from:root. The old variable lingers until the component unmounts. Prefer stable variable names. - iPhone home indicator (safe-area-inset): on devices with a home bar, combine
--keyboard-heightwithenv(safe-area-inset-bottom)to avoid the bottom safe area:
.input-bar {
padding-bottom: calc(var(--keyboard-height, 0px) + env(safe-area-inset-bottom, 0px));
}Browser support
Requires window.visualViewport (95%+ global support). Falls back to window.innerHeight when not available, meaning --keyboard-height will always be 0px in unsupported browsers.
License
MIT © parkgichan
