DEV Community

cederhook
cederhook

Posted on

Fixing the iOS standalone-PWA keyboard bug that shrinks your viewport for good

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-visual in 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: fixed ancestor — real advice for Safari, but doesn't stop the standalone canvas from shrinking.
  • window.scrollTo(0,0), blurring, visualViewport listenersscrollY is already 0 and visualViewport.height is also stuck.
  • Replacing 100vh/100dvh with height: 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));
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)