Our bottom sheets worked everywhere we tested: Chrome, Samsung Internet, Firefox, Safari. Then we tried the default browser of a budget Redmi phone: the sign-in sheet sat under the on-screen keyboard, and the password field was invisible.
What was going on
The phone's default browser is a thin shell over Android System WebView. Since Android 15, apps that target SDK 35 are drawn edge-to-edge by default, and adjustResize no longer shrinks the window unless the app handles insets itself. Many small browser shells don't. So when the keyboard opens, the page gets nothing:
- no resize on window;
- no resize on visualViewport, and its height stays the same;
- navigator.virtualKeyboard doesn't exist.
There is nothing to listen to. The keyboard covers the bottom 40% of the screen, and the page has no idea.
The fix: assume the worst after a short wait
If we can't detect the keyboard, we can detect that the browser failed to report it. A text field gets focus; on a phone, that almost always means the keyboard is coming. Wait a bit. If the visible height hasn't dropped, the browser is silent, and we move the sheet to the top of the screen, where the keyboard can't reach it.
(() => {
const vv = window.visualViewport;
// iOS reports the keyboard in every browser, and a script-focused field
// doesn't raise it there, so the sheet would jump for nothing
if (!vv || CSS.supports('-webkit-touch-callout', 'none')) return;
const phone = matchMedia('(max-width:599px) and (pointer:coarse)');
// full height = the largest we've seen; reset on rotation
let full = vv.height;
vv.addEventListener('resize', () => { full = Math.max(full, vv.height); });
matchMedia('(orientation:portrait)')
.addEventListener?.('change', () => { full = vv.height; });
const field = el => el?.matches?.(
'input:not([type=checkbox],[type=radio],[type=hidden]),textarea');
const sheet = el => el?.closest?.('dialog.sheet[open]');
document.addEventListener('focusin', e => {
const d = sheet(e.target);
if (!d || !field(e.target) || !phone.matches) return;
setTimeout(() => {
if (document.activeElement === e.target && vv.height > full * 0.85)
d.classList.add('kb-top');
}, 600);
});
document.addEventListener('focusout', e => {
const d = sheet(e.target);
// moving between fields of the same sheet keeps it on top
if (d && !(sheet(e.relatedTarget) === d && field(e.relatedTarget)))
d.classList.remove('kb-top');
});
})();
.kb-top just flips the sheet: top: 0; bottom: auto, rounded corners at the bottom instead of the top, and swipe-to-close disabled, since there is nowhere to swipe it.
Why these numbers
- 600 ms. Long enough for the keyboard animation to finish and for browsers that do report it to fire resize. Short enough that the person hasn't started typing blind.
- 85%. A real keyboard takes 35–50% of a portrait screen. Toolbar show/hide changes the height by 5–10%. Anything above 85% of the full height means "no keyboard reported".
- The largest height seen, not the current one. Opening the page with the address bar visible and then scrolling changes the baseline, so we keep the maximum.
This runs on Kinomig, a free movie site that a lot of people open on budget Android phones. If you've seen another browser that stays silent about the keyboard, I'd like to hear which one.
Top comments (0)