Improve dialog scrolling behavior when the keyboard is open

Adjust dialog content to enable scrolling and center focused input fields, addressing issues on iOS and Android where content was hidden by the on-screen keyboard.
This commit is contained in:
Riyadh
2026-05-21 07:40:44 +00:00
parent 92953cfb4a
commit 8980eedae2
+78 -1
View File
@@ -39,6 +39,76 @@ const DialogContent = React.forwardRef<
// changes are ignored so desktop and normal mobile scrolling stay
// untouched.
const keyboardOpen = keyboardInset > 80
const innerRef = React.useRef<HTMLDivElement | null>(null)
// Merge the consumer-provided forwardRef with our own ref so we can
// attach focus listeners without breaking Radix's portal/animation
// wiring (which relies on receiving a working ref).
const setRefs = React.useCallback(
(node: HTMLDivElement | null) => {
innerRef.current = node
if (typeof ref === "function") ref(node)
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node
},
[ref],
)
// iOS PWA + Android Chrome both mis-handle focus inside a position:fixed
// dialog when the soft keyboard opens:
// * iOS scrolls the *layout* viewport upward trying to reveal the
// focused input, which slides our fixed-positioned dialog off the
// top of the screen. The text the user is typing disappears.
// * Neither browser scrolls the focused field into view inside the
// dialog's own scroll container, so tall forms (saved notes,
// user profile, executive meetings) lose the focused field below
// the keyboard or above the visible band.
// The fix is dialog-wide so every form benefits without page edits:
// 1. Undo iOS's layout-viewport scroll (`window.scrollTo(0, 0)`).
// 2. After the keyboard animation settles, scrollIntoView({block:
// 'center'}) on the focused field — this scrolls the dialog's
// own overflow container, since the dialog is the nearest
// scrollable ancestor when keyboardOpen flips on overflow-y.
React.useEffect(() => {
const node = innerRef.current
if (!node) return
const onFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement | null
if (!target) return
const tag = target.tagName
if (
tag !== "INPUT" &&
tag !== "TEXTAREA" &&
tag !== "SELECT" &&
!target.isContentEditable
) {
return
}
// Undo iOS layout-viewport push-up immediately, then again after
// the keyboard finishes animating (~250ms on iPad).
const reset = () => {
try {
window.scrollTo(0, 0)
} catch {}
try {
target.scrollIntoView({ block: "center", behavior: "smooth" })
} catch {
target.scrollIntoView()
}
}
requestAnimationFrame(reset)
const t = window.setTimeout(reset, 300)
const t2 = window.setTimeout(reset, 600)
target.addEventListener(
"blur",
() => {
clearTimeout(t)
clearTimeout(t2)
},
{ once: true },
)
}
node.addEventListener("focusin", onFocusIn)
return () => node.removeEventListener("focusin", onFocusIn)
}, [])
const dynamicStyle: React.CSSProperties = keyboardOpen
? {
// Mobile-sheet behaviour when an on-screen keyboard is open.
@@ -81,6 +151,13 @@ const DialogContent = React.forwardRef<
maxWidth: "none",
maxHeight: `${height - 8}px`,
transform: "none",
// Make the dialog itself the scrollable region above the
// keyboard so scrollIntoView() on the focused field actually
// moves it into view (its nearest scrollable ancestor is now
// the dialog, not the body which can't scroll while a
// position:fixed parent sits on top).
overflowY: "auto",
WebkitOverflowScrolling: "touch",
...style,
}
: (style ?? {})
@@ -88,7 +165,7 @@ const DialogContent = React.forwardRef<
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
ref={setRefs}
style={dynamicStyle}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",