If you've shipped an "Add to Home Screen" PWA on iOS with a text input anywhere in it, you may have hit this: the first time the software keyboard opens, the viewport shrinks — and it never grows back until the app is force-quit.
window.innerHeight, visualViewport.height and 100dvh all drop (e.g. 932 → 873 on an iPhone Pro Max) and stay there. Your position: fixed bottom bar floats ~59px too high, content gets clipped, and there's a dead black band at the bottom of every screen for the rest of the session.
What does NOT fix it (I tried all of these)
-
interactive-widget=resizes-visualin the viewport meta — ignored in standalone mode. Helps in mobile Safari, not the installed PWA. -
Locking the shell (
html, body { position: fixed; inset: 0; overflow: hidden }) — pins to the small viewport, so the UI is wrong from load. -
Moving the input out of any
position: fixedancestor — real advice for Safari, but doesn't stop the standalone canvas from shrinking. -
window.scrollTo(0,0), blurring,visualViewportlisteners —scrollYis already 0 andvisualViewport.heightis also stuck. -
Replacing
100vh/100dvhwithheight: 100%up the chain — the most upvoted advice, and it did not work for me: didn't prevent the shrink, broke my inner scroll containers, and sized the shell to the small viewport on first load. A trap if you use an inner scroll container with fixed chrome.
The insight: don't prevent the shrink — trigger the restore
I noticed by accident that re-navigating in my SPA snapped the viewport back to full height. My router toggles display: none → flex on the full-screen view element on every nav, and that flip on a full-viewport-height element forces WebKit to recompute the viewport — the canvas jumps back to its real height.
So: force a re-measure after the keyboard closes, by flipping display off→on on a full-height element with a synchronous reflow in between:
let maxVH = window.innerHeight;
addEventListener('resize', () => { maxVH = Math.max(maxVH, window.innerHeight); });
function healViewport() {
if (maxVH - window.innerHeight <= 4) return; // only when actually stuck
const el = document.querySelector('#app'); // a full-VIEWPORT-HEIGHT element
const scroller = document.querySelector('#scroll');
const st = scroller ? scroller.scrollTop : 0;
el.style.display = 'none';
void el.offsetHeight; // sync reflow, no paint between
el.style.display = '';
if (scroller) scroller.scrollTop = st; // restore scroll
}
input.addEventListener('blur', () => setTimeout(healViewport, 140));
After blur, innerHeight reads the real value again, the fixed bar drops back, and the black band is gone — no force-quit.
Hiding the flip
The flip causes a one-frame flicker plus a settle bounce. Cover it with a short backdrop-filter: blur() veil: fade in, flip while fully up, hold a beat, fade out slowly:
const veil = document.createElement('div');
veil.style.cssText =
'position:fixed;inset:0;z-index:9000;opacity:0;pointer-events:none;' +
'background:rgba(20,18,21,.3);backdrop-filter:blur(26px);' +
'-webkit-backdrop-filter:blur(26px)';
document.body.appendChild(veil);
function healMasked() {
if (maxVH - window.innerHeight <= 4) return;
veil.style.transition = 'opacity .2s ease-out';
veil.style.opacity = '1';
setTimeout(healViewport, 230); // flip while fully blurred
setTimeout(() => {
veil.style.transition = 'opacity .55s cubic-bezier(.32,.72,0,1)';
veil.style.opacity = '0';
}, 380);
}
Caveats
- Standalone (
display: standalone) PWA only; mobile Safari differs. - Tested on iOS 17/18 — behavior shifts between versions, verify on your targets.
- The healed element must actually be full viewport height for the re-measure to fire.
Spent a full evening on this, so hopefully it saves someone else theirs.
Top comments (0)