Validating Forms with the Virtual Keyboard Open
A user fills a field near the bottom of a phone screen, taps “Next”, and the error for that field appears — underneath the keyboard. They never see it. On submit, the browser focuses the first invalid field and scrolls it into view, but the virtual keyboard opens at the same moment and covers the message. This recipe keeps validation messages visible while the keyboard is open. It measures the visual viewport (the part of the page not covered by the keyboard) with the visualViewport API, scrolls the invalid field together with its message into that area, handles the difference between iOS (keyboard overlays the page) and Android (page may resize), and hooks into the site’s canonical reportValidity() flow from the Constraint Validation API so focus and announcement still come from the browser.
When You Need Keyboard-Aware Scrolling
You need it on any form used on phones where errors appear below inputs, which is the default in most design systems. It is especially important when:
- Fields near the bottom of the screen show messages below them.
- Errors appear on blur while the user moves to the next field with the keyboard still open.
- A sticky footer (a “Continue” bar) sits above the keyboard and covers even more of the viewport.
- Messages are multi-line, which extends further under the keyboard.
Placing messages above inputs avoids much of this, as discussed in touch-friendly error message placement, but most forms still need the measurement below for the submit-time focus case. The broader mobile context is in mobile form validation UX.
Minimal Working Keyboard-Aware Reveal
/** Scroll so that `el` and its error message sit inside the visible area above the keyboard. */
export function revealWithMessage(field: HTMLElement, opts: { margin?: number; footer?: HTMLElement | null } = {}): void {
const vv = window.visualViewport;
const margin = opts.margin ?? 16;
const describedBy = field.getAttribute("aria-describedby")?.split(/\s+/) ?? [];
const message = describedBy.map((id) => document.getElementById(id)).find((m) => m && !m.hidden) ?? null;
// The block to show: from the field's label (if above) down to the message (if below).
const label = field.id ? document.querySelector<HTMLElement>(`label[for="${field.id}"]`) : null;
const top = Math.min(field.getBoundingClientRect().top, label?.getBoundingClientRect().top ?? Infinity);
const bottom = Math.max(field.getBoundingClientRect().bottom, message?.getBoundingClientRect().bottom ?? -Infinity);
// Visible band in client coordinates.
const visibleTop = vv ? vv.offsetTop : 0;
const footerHeight = opts.footer && getComputedStyle(opts.footer).position === "fixed" ? opts.footer.offsetHeight : 0;
const visibleBottom = (vv ? vv.offsetTop + vv.height : window.innerHeight) - footerHeight;
let delta = 0;
if (bottom + margin > visibleBottom) delta = bottom + margin - visibleBottom; // push up above keyboard
if (top - margin - delta < visibleTop) delta = top - margin - visibleTop; // but never hide the label
if (delta !== 0) window.scrollBy({ top: delta, behavior: prefersReducedMotion() ? "auto" : "smooth" });
}
const prefersReducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
// 1. After a field's error is shown on blur, keep the NEXT focused field and this message visible.
const form = document.querySelector<HTMLFormElement>("#details")!;
const footer = document.querySelector<HTMLElement>(".sticky-footer");
form.addEventListener("focusout", (event) => {
const field = event.target as HTMLInputElement;
if (!field.matches("input, select, textarea")) return;
showErrorFor(field); // your existing blur validation
});
form.addEventListener("focusin", (event) => {
// Wait for the keyboard animation to settle (visualViewport resize) before measuring.
const field = event.target as HTMLElement;
const run = () => revealWithMessage(field, { footer });
window.visualViewport?.addEventListener("resize", run, { once: true });
setTimeout(run, 350); // fallback if no resize event fires
});
// 2. On submit: let reportValidity focus and announce, then correct the scroll.
form.addEventListener("submit", (event) => {
if (form.checkValidity()) return;
event.preventDefault();
const first = form.querySelector<HTMLElement>(":invalid")!;
form.reportValidity(); // native focus + announcement
requestAnimationFrame(() => revealWithMessage(first, { footer }));
});
Two measurements do all the work. visualViewport.offsetTop and height describe the band the user can actually see; comparing the bottom of the field’s message (found through its aria-describedby) with the bottom of that band tells you exactly how far to scroll. Waiting for the visualViewport resize event matters because the keyboard animates open over a few hundred milliseconds, and measuring immediately after focus gives the pre-keyboard viewport.
Viewport and Keyboard Reference
| API / setting | Meaning | Use |
|---|---|---|
visualViewport.height |
Height of the visible area | Bottom of the visible band = offsetTop + height |
visualViewport.offsetTop |
Visible area’s offset from the layout viewport top | Top of the visible band |
visualViewport resize event |
Keyboard opened, closed or zoom changed | Re-measure after it fires |
<meta name="viewport" content="…, interactive-widget=resizes-content"> |
Android: keyboard resizes the layout | Makes 100dvh layouts shrink with the keyboard |
interactive-widget=overlays-content |
Keyboard overlays; layout unchanged | Matches iOS behaviour |
scroll-padding-bottom (CSS) |
Extra space kept when scrolling into view | Useful with sticky footers |
focus({ preventScroll: true }) |
Focus without the browser’s own scroll | When you scroll precisely yourself |
Verification Steps
import { test, expect, devices } from "@playwright/test";
test.use({ ...devices["Pixel 7"] });
test("message is inside the visible band after submit", async ({ page }) => {
await page.goto("/details");
// Simulate a keyboard by shrinking the viewport height, as a real keyboard would.
await page.setViewportSize({ width: 412, height: 420 });
await page.getByRole("button", { name: "Continue" }).click();
await page.waitForTimeout(400);
const msg = await page.locator(".field-error:not([hidden])").first().boundingBox();
expect(msg!.y + msg!.height).toBeLessThanOrEqual(420);
});
Edge Cases and Failure Modes
Measuring too early. Reading visualViewport.height on focus returns the height before the keyboard opens. Wait for the resize event (with a timeout fallback, since some browsers do not fire it when nothing resizes).
Scrolling the label off the top. Pushing the field up far enough to show a long message can hide its label. The reveal function stops scrolling when the label would leave the visible band; the label is more important than the last line of a long message.
Scroll containers. Forms inside a scrollable panel (a modal, a drawer) need to scroll the panel, not the window. Use scrollIntoView({ block: "nearest" }) on a wrapper that includes field and message, or adapt the function to call panel.scrollBy.
iOS auto-zoom. Inputs with a font size under 16 pixels make iOS zoom in on focus, which changes the visual viewport again and can move the error out of view. Use at least 16 pixels for inputs.
Screen Readers and Keyboard-Aware Scrolling
Programmatic scrolling must never fight assistive technology. VoiceOver and TalkBack move their own focus and scroll the page to keep it visible; a script that scrolls on every focusin can yank the page away from where the screen reader placed it. Keep the reveal function conservative: scroll only when the field or its message is actually outside the visible band, never re-centre a field that is already visible, and skip the correction entirely when the element with focus is not the one you measured. The announcement itself comes from focus and aria-describedby, so it is unaffected by where the message sits on screen.
Designing So Less Scrolling Is Needed
Measurement fixes the symptom; layout choices reduce it. Keep forms short per screen on mobile, so fewer fields sit near the bottom. Put messages between label and input where your design system allows it. Avoid sticky footers on long forms, or hide them while an input is focused — the keyboard already provides a way forward with Enter labelled “next”. And prefer a single column; multi-column forms on a phone push fields further down and make their messages harder to associate. These choices, together with the placement options in touch-friendly error message placement, often remove the need for anything more than the browser’s default scrolling.
/* Hide a sticky action bar while typing, so it never covers fields or messages. */
@media (max-width: 40rem) {
body:has(input:focus, textarea:focus, select:focus) .sticky-footer { position: static; }
}
Frequently Asked Questions
Why is my validation error hidden on mobile?
The virtual keyboard covers the lower part of the screen. An error placed below a field near the bottom ends up under the keyboard. Scroll the field and its message into the visible area above the keyboard, or place messages above inputs.
How do I know where the mobile keyboard is?
Use window.visualViewport. Its offsetTop and height describe the part of the page the user can see; the keyboard occupies the space below it. Listen for its resize event to know when the keyboard has opened.
Does reportValidity scroll the invalid field into view on mobile?
Yes, but the keyboard often opens at the same time and covers the message. Call reportValidity for focus and announcement, then adjust the scroll once the visual viewport has resized.
Why does iOS zoom into my form when a field is focused?
iOS zooms on focus when the input's font size is below 16 pixels. Use at least 16 pixels for inputs to avoid the zoom and the resulting shift of error messages.
Related Guides
- Mobile Form Validation UX — the mobile validation overview.
- Touch-Friendly Error Message Placement — layouts that need less scrolling.
- Managing Focus After Validation Failure — the focus rules this builds on.
- Reserving Space for Error Messages to Prevent Layout Shift — stable layouts while messages appear.