The iOS Safari keyboard scroll bug, fixed with one line of CSS
If you build a full-screen mobile editor as a position: fixed overlay with a fixed toolbar on top and a nav bar on the bottom, iOS Safari will happily scroll your entire chrome off-screen the moment the soft keyboard opens - but only when the content is short. The fix isn't a JavaScript viewport dance. It's one line: .editor .ProseMirror { padding-bottom: 60vh; } Give the inner scroll container something to scroll, and iOS keeps the scroll inside it instead of falling back to scrolling the document (which drags your "fixed" elements along). No html /body locking required. I hit this while building the mobile editor for PenPage, a local-first WYSIWYG markdown notes app (React + TipTap/ProseMirror). Everything below is verified on a real iOS device. The setup Picture a mobile note editor that takes over the whole screen: ββββββββββββββββββββββββββββ β Toolbar (absolute,top) β β stays put ββββββββββββββββββββββββββββ€ β β β Editable content β β scrolls β (overflow-y: auto) β β β ββββββββββββββββββββββββββββ€ β Nav bar (absolute,bottom)β β stays put ββββββββββββββββββββββββββββ The outer container is position: fixed; inset: 0 . The toolbar and nav bar are position: absolute inside it. The middle is the only thing that scrolls. Standard app-shell layout. Works great on desktop and Android. The symptom Tap into the editor, the iOS keyboard slides up, and: - Long document (taller than the viewport): perfect. The content scrolls under the keyboard, the toolbar and nav bar stay nailed in place. - Short document (shorter than the viewport): broken. Trying to scroll drags the whole screen - toolbar and nav bar included - as if the entire fixed overlay were a normal scrolling page. That "only when short" detail is the whole story. The root cause This is the long tail of WebKit bug #191204: when the soft keyboard appears, iOS Safari's layout viewport gets shorter than the visual viewport, and the document itself becomes scrollable by the keyboard's height. Worse, in that state position: fixed / position: absolute elements stop being pinned to the viewport and start moving with document scroll. Here's the key insight about when it bites you: - When your inner scroll container has room to scroll, the touch gesture is consumed there. iOS never needs to escalate to the document. Your fixed chrome stays put. - When the inner container has nothing to scroll (short content fits in the keyboard-shrunk viewport), iOS falls back to scrolling the document - and now the buggy viewport state takes over and your "fixed" toolbar and nav bar slide away. So "long content works" was never luck. The long content was quietly absorbing the scroll the whole time. The fix everyone reaches for (and why we skipped it) The canonical workaround is the app-shell lock: pin html and body to the viewport so the document can't scroll at all, then move scrolling into an inner wrapper. @media (orientation: portrait) { html.app-shell, html.app-shell body { position: fixed; inset: 0; overflow: hidden; overscroll-behavior: none; } } It works. We use exactly this in the project's legacy editor. But it's invasive: you're globally locking the page, you have to add/remove the state around mount/route changes, and it interacts with scroll-restoration, :has() scoping, and every other overlay you have. For a brand-new, self-contained overlay it felt like a sledgehammer. The fix that actually shipped Since the real trigger is "the inner container has nothing to scroll," the cure is to make sure it always does. Add a tall chunk of empty, scrollable space to the bottom of the editable area: .editor .ProseMirror { min-height: calc(100dvh - 200px); /* fill the viewport so taps land in the editor / padding-bottom: 60vh; / always-present scroll headroom / } Now even an empty note has 60vh of scrollable runway below the cursor. The inner container is always scrollable, iOS never escalates to document scroll, and the toolbar/nav bar stay frozen. No JS, no global locks, no lifecycle wiring. A nice bonus: because the padding is inside the contenteditable box, tapping that empty space still focuses the editor and drops the caret at the end - it reads as "tap below the text to keep writing," exactly like a native notes app. Tuning the number We trialed it live with a translucent red background on the filler so we could see it: / debugging only */ .editor .ProseMirror::after { content: ''; display: block; height: 100vh; background: rgba(255, 0, 0, 0.12); } - 100vh - fixed the bug, but you can scroll the whole note completely off the top of the screen. Disorienting. - 80vh - better. - 60vh - the sweet spot: enough headroom to lift the caret line above the keyboard, not so much that the content vanishes. Then drop the debug background and fold it back into a plain padding-bottom . Caveats - The filler must live inside the element that scrolls (here, the contenteditable ), not in some outer wrapper. The point is to keep that container scrollable. - vh vsdvh : we usedvh for the filler (a fixed, generous amount is fine) anddvh formin-height (so the editor fills the dynamic viewport). Mixing them on purpose. - This addresses the scroll-hijack symptom. There's a sibling bug at the moment you enter editing - see below. - Tested on iOS Safari. Android/desktop never had the bug; the extra padding is harmless there. The sequel: the toolbar still jumps on tap-to-edit The filler kills the gesture-scroll hijack. But there's a second, sneakier trigger with the same victim (the top toolbar) and a different cause. Our editor is always-editable: "view mode" is just the editor unfocused (no keyboard), "edit mode" is focused. Now try this: in view mode, tap low on the screen - in the bottom third that the keyboard is about to cover - to start editing there. iOS pops the keyboard and, because your caret is now underneath it, auto-scrolls the caret up into the visible band. That scroll is focus-time keyboard-reveal scroll, not a touch gesture - so the filler never gets a chance to absorb it. It shifts the whole layout viewport, and your position: absolute toolbar rides off the top edge. (Scroll back down, or blur, and it returns - classic #191204 transient.) The caret landing above the keyboard is exactly what you want. The only defect is the toolbar going with it. Fix: pre-scroll on focus, so iOS has nothing to reveal The reveal-scroll only fires because the caret is under the keyboard. Remove the reason and you remove the effect: on the viewβedit transition (i.e. on focus ), synchronously scroll the inner container so the tapped line sits at ~1/3 of the viewport height - comfortably above where the keyboard will land. When iOS then evaluates "is the caret visible?", it already is, so its reveal-scroll is a no-op and the layout viewport never shifts. The toolbar doesn't move at all - no visible jump-and-correct, unlike compensating after the fact with a margin-top nudge. Two implementation notes that make it robust: - Use the tap coordinate, not the selection. Record the pointer's clientY on a passivepointerdown on the scroll container (passive = neverpreventDefault , so it can't swallow your other touch gestures). The tapped Y is where the caret will land - you don't need to resolve it to a document position, and you dodge the "has ProseMirror synced the selection yet on focus?" timing question. - Scroll synchronously in the focus handler, before the keyboard animates in. scrollTop is instant; the reveal-scroll happens later, tied to the keyboard geometry. You win the race by construction. const pendingTapY = useRef(null) // passive: records where the tap landed, never blocks a gesture scroller.addEventListener('pointerdown', (e) => { pendingTapY.current = e.clientY }, { passive: true }) // runs on the editor's / textarea's focus (the viewβedit transition) function preScrollIntoSafeZone() { const tapY = pendingTapY.current pendingTapY.current = null // consume, so non-tap focus won't trigger it if (tapY == null) return const vh = window.visualViewport?.height || window.innerHeight if (tapY < vh * 0.55) return // upper half is already visible; leave it scroller.scrollTop += tapY - vh / 3 // lift the tapped line to ~1/3 height } 0.55 and 1/3 are feel-tuned on-device. The filler from the first half of this article is what guarantees there's always room to do this scroll. Same principle, restated: the first fix keeps iOS from escalating a gesture scroll to the document; this one keeps iOS from needing a focus scroll at all. Two triggers, one victim, two one-liners - no html/body lock, no viewport-metric reflow tricks (those don't work; the WebKit metric is already corrupted by the time your JS runs). Takeaway When iOS Safari's keyboard scrolls your fixed UI away, don't assume you need the heavy app-shell html/body lock. First ask: does my inner scroll container always have room to scroll? If short content can leave it un-scrollable, a generous padding-bottom on the scrollable element is often the entire fix - one declaration, zero JavaScript. I ran into all of this building PenPage - a free, local-first WYSIWYG markdown editor that runs entirely in your browser (no sign-up, notes stay in IndexedDB). If you try the mobile editor, everything in this article is what keeps the toolbar from flying away. Top comments (0)
Comments
No comments yet. Start the discussion.