<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dean Liu</title>
    <description>The latest articles on DEV Community by Dean Liu (@deanliu).</description>
    <link>https://dev.to/deanliu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4074483%2F89b6eb9f-15d6-4803-8467-581c74e66c34.png</url>
      <title>DEV Community: Dean Liu</title>
      <link>https://dev.to/deanliu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deanliu"/>
    <language>en</language>
    <item>
      <title>The iOS Safari keyboard scroll bug, fixed with one line of CSS</title>
      <dc:creator>Dean Liu</dc:creator>
      <pubDate>Wed, 12 Aug 2026 09:30:01 +0000</pubDate>
      <link>https://dev.to/deanliu/the-ios-safari-keyboard-scroll-bug-fixed-with-one-line-of-css-1353</link>
      <guid>https://dev.to/deanliu/the-ios-safari-keyboard-scroll-bug-fixed-with-one-line-of-css-1353</guid>
      <description>&lt;p&gt;If you build a full-screen mobile editor as a &lt;code&gt;position: fixed&lt;/code&gt; overlay with a &lt;strong&gt;fixed toolbar on top and a nav bar on the bottom&lt;/strong&gt;, iOS Safari will happily scroll your &lt;em&gt;entire chrome&lt;/em&gt; off-screen the moment the soft keyboard opens — but &lt;strong&gt;only when the content is short&lt;/strong&gt;. The fix isn't a JavaScript viewport dance. It's one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.editor&lt;/span&gt; &lt;span class="nc"&gt;.ProseMirror&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;padding-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60vh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give the &lt;strong&gt;inner scroll container something to scroll&lt;/strong&gt;, and iOS keeps the scroll inside it instead of falling back to scrolling the document (which drags your "fixed" elements along). No &lt;code&gt;html&lt;/code&gt;/&lt;code&gt;body&lt;/code&gt; locking required.&lt;/p&gt;

&lt;p&gt;I hit this while building the mobile editor for &lt;a href="https://penpage.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ios-keyboard" rel="noopener noreferrer"&gt;PenPage&lt;/a&gt;, a local-first WYSIWYG markdown notes app (React + TipTap/ProseMirror). Everything below is verified on a real iOS device.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Picture a mobile note editor that takes over the whole screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────┐
│  Toolbar   (absolute,top) │  ← stays put
├──────────────────────────┤
│                          │
│  Editable content        │  ← scrolls
│  (overflow-y: auto)      │
│                          │
├──────────────────────────┤
│  Nav bar (absolute,bottom)│  ← stays put
└──────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outer container is &lt;code&gt;position: fixed; inset: 0&lt;/code&gt;. The toolbar and nav bar are &lt;code&gt;position: absolute&lt;/code&gt; inside it. The middle is the only thing that scrolls. Standard app-shell layout. Works great on desktop and Android.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Tap into the editor, the iOS keyboard slides up, and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Long document&lt;/strong&gt; (taller than the viewport): perfect. The content scrolls under the keyboard, the toolbar and nav bar stay nailed in place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short document&lt;/strong&gt; (shorter than the viewport): broken. Trying to scroll drags the &lt;strong&gt;whole screen&lt;/strong&gt; — toolbar and nav bar included — as if the entire fixed overlay were a normal scrolling page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That "only when short" detail is the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause
&lt;/h2&gt;

&lt;p&gt;This is the long tail of &lt;a href="https://bugs.webkit.org/show_bug.cgi?id=191204" rel="noopener noreferrer"&gt;WebKit bug #191204&lt;/a&gt;: when the soft keyboard appears, iOS Safari's &lt;em&gt;layout viewport&lt;/em&gt; gets shorter than the &lt;em&gt;visual viewport&lt;/em&gt;, and the document itself becomes scrollable by the keyboard's height. Worse, in that state &lt;code&gt;position: fixed&lt;/code&gt; / &lt;code&gt;position: absolute&lt;/code&gt; elements stop being pinned to the viewport and start moving with document scroll.&lt;/p&gt;

&lt;p&gt;Here's the key insight about &lt;em&gt;when&lt;/em&gt; it bites you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When your &lt;strong&gt;inner&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;When the inner container has &lt;strong&gt;nothing to scroll&lt;/strong&gt; (short content fits in the keyboard-shrunk viewport), iOS falls back to scrolling the &lt;strong&gt;document&lt;/strong&gt; — and now the buggy viewport state takes over and your "fixed" toolbar and nav bar slide away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "long content works" was never luck. The long content was quietly absorbing the scroll the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix everyone reaches for (and why we skipped it)
&lt;/h2&gt;

&lt;p&gt;The canonical workaround is the &lt;strong&gt;app-shell lock&lt;/strong&gt;: pin &lt;code&gt;html&lt;/code&gt; and &lt;code&gt;body&lt;/code&gt; to the viewport so the document can't scroll at all, then move scrolling into an inner wrapper.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orientation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;portrait&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="nc"&gt;.app-shell&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="nc"&gt;.app-shell&lt;/span&gt; &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;overscroll-behavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. We use exactly this in the project's &lt;em&gt;legacy&lt;/em&gt; 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, &lt;code&gt;:has()&lt;/code&gt; scoping, and every other overlay you have. For a brand-new, self-contained overlay it felt like a sledgehammer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that actually shipped
&lt;/h2&gt;

&lt;p&gt;Since the real trigger is "the inner container has nothing to scroll," the cure is to &lt;strong&gt;make sure it always does&lt;/strong&gt;. Add a tall chunk of empty, scrollable space to the bottom of the editable area:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.editor&lt;/span&gt; &lt;span class="nc"&gt;.ProseMirror&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="n"&gt;dvh&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="m"&gt;200px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c"&gt;/* fill the viewport so taps land in the editor */&lt;/span&gt;
  &lt;span class="nl"&gt;padding-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60vh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c"&gt;/* always-present scroll headroom */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now even an empty note has 60vh of scrollable runway below the cursor. The inner container is &lt;em&gt;always&lt;/em&gt; scrollable, iOS &lt;em&gt;never&lt;/em&gt; escalates to document scroll, and the toolbar/nav bar stay frozen. No JS, no global locks, no lifecycle wiring.&lt;/p&gt;

&lt;p&gt;A nice bonus: because the padding is inside the &lt;code&gt;contenteditable&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning the number
&lt;/h2&gt;

&lt;p&gt;We trialed it live with a translucent red background on the filler so we could &lt;em&gt;see&lt;/em&gt; it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* debugging only */&lt;/span&gt;
&lt;span class="nc"&gt;.editor&lt;/span&gt; &lt;span class="nc"&gt;.ProseMirror&lt;/span&gt;&lt;span class="nd"&gt;::after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100vh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;100vh&lt;/strong&gt; — fixed the bug, but you can scroll the whole note completely off the top of the screen. Disorienting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;80vh&lt;/strong&gt; — better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;60vh&lt;/strong&gt; — the sweet spot: enough headroom to lift the caret line above the keyboard, not so much that the content vanishes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then drop the debug background and fold it back into a plain &lt;code&gt;padding-bottom&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The filler must live &lt;strong&gt;inside&lt;/strong&gt; the element that scrolls (here, the &lt;code&gt;contenteditable&lt;/code&gt;), not in some outer wrapper. The point is to keep &lt;em&gt;that&lt;/em&gt; container scrollable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vh&lt;/code&gt; vs &lt;code&gt;dvh&lt;/code&gt;: we used &lt;code&gt;vh&lt;/code&gt; for the filler (a fixed, generous amount is fine) and &lt;code&gt;dvh&lt;/code&gt; for &lt;code&gt;min-height&lt;/code&gt; (so the editor fills the dynamic viewport). Mixing them on purpose.&lt;/li&gt;
&lt;li&gt;This addresses the &lt;em&gt;scroll-hijack&lt;/em&gt; symptom. There's a sibling bug at the moment you &lt;em&gt;enter&lt;/em&gt; editing — see below.&lt;/li&gt;
&lt;li&gt;Tested on iOS Safari. Android/desktop never had the bug; the extra padding is harmless there.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The sequel: the toolbar still jumps on tap-to-edit
&lt;/h2&gt;

&lt;p&gt;The filler kills the &lt;strong&gt;gesture-scroll&lt;/strong&gt; hijack. But there's a second, sneakier trigger with the &lt;em&gt;same&lt;/em&gt; victim (the top toolbar) and a &lt;em&gt;different&lt;/em&gt; cause.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;low on the screen&lt;/strong&gt; — 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, &lt;strong&gt;auto-scrolls the caret up&lt;/strong&gt; into the visible band. That scroll is &lt;em&gt;focus-time keyboard-reveal scroll&lt;/em&gt;, not a touch gesture — so the filler never gets a chance to absorb it. It shifts the whole layout viewport, and your &lt;code&gt;position: absolute&lt;/code&gt; toolbar rides off the top edge. (Scroll back down, or blur, and it returns — classic #191204 transient.)&lt;/p&gt;

&lt;p&gt;The caret landing above the keyboard is exactly what you want. The only defect is the toolbar going with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix: pre-scroll on focus, so iOS has nothing to reveal
&lt;/h3&gt;

&lt;p&gt;The reveal-scroll only fires &lt;em&gt;because&lt;/em&gt; the caret is under the keyboard. Remove the reason and you remove the effect: on the view→edit transition (i.e. on &lt;code&gt;focus&lt;/code&gt;), &lt;strong&gt;synchronously scroll the inner container so the tapped line sits at ~1/3 of the viewport height&lt;/strong&gt; — 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 &lt;code&gt;margin-top&lt;/code&gt; nudge.&lt;/p&gt;

&lt;p&gt;Two implementation notes that make it robust:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use the tap coordinate, not the selection.&lt;/strong&gt; Record the pointer's &lt;code&gt;clientY&lt;/code&gt; on a &lt;strong&gt;passive&lt;/strong&gt; &lt;code&gt;pointerdown&lt;/code&gt; on the scroll container (passive = never &lt;code&gt;preventDefault&lt;/code&gt;, so it can't swallow your other touch gestures). The tapped Y &lt;em&gt;is&lt;/em&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scroll synchronously in the focus handler&lt;/strong&gt;, before the keyboard animates in. &lt;code&gt;scrollTop&lt;/code&gt; is instant; the reveal-scroll happens later, tied to the keyboard geometry. You win the race by construction.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pendingTapY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// passive: records where the tap landed, never blocks a gesture&lt;/span&gt;
&lt;span class="nx"&gt;scroller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pointerdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pendingTapY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// runs on the editor's / textarea's focus (the view→edit transition)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;preScrollIntoSafeZone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tapY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pendingTapY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;
  &lt;span class="nx"&gt;pendingTapY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;            &lt;span class="c1"&gt;// consume, so non-tap focus won't trigger it&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tapY&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visualViewport&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHeight&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tapY&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;vh&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;          &lt;span class="c1"&gt;// upper half is already visible; leave it&lt;/span&gt;
  &lt;span class="nx"&gt;scroller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;tapY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;vh&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;   &lt;span class="c1"&gt;// lift the tapped line to ~1/3 height&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0.55&lt;/code&gt; and &lt;code&gt;1/3&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Same principle, restated: the first fix keeps iOS from escalating a &lt;strong&gt;gesture&lt;/strong&gt; scroll to the document; this one keeps iOS from needing a &lt;strong&gt;focus&lt;/strong&gt; scroll at all. Two triggers, one victim, two one-liners — no &lt;code&gt;html/body&lt;/code&gt; lock, no viewport-metric reflow tricks (those don't work; the WebKit metric is already corrupted by the time your JS runs).&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;When iOS Safari's keyboard scrolls your fixed UI away, don't assume you need the heavy app-shell &lt;code&gt;html/body&lt;/code&gt; lock. First ask: &lt;strong&gt;does my inner scroll container always have room to scroll?&lt;/strong&gt; If short content can leave it un-scrollable, a generous &lt;code&gt;padding-bottom&lt;/code&gt; on the scrollable element is often the entire fix — one declaration, zero JavaScript.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I ran into all of this building &lt;a href="https://penpage.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ios-keyboard" rel="noopener noreferrer"&gt;PenPage&lt;/a&gt; — 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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ios</category>
      <category>css</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
