<?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: Kyoko Otonashi</title>
    <description>The latest articles on DEV Community by Kyoko Otonashi (@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%2F6de4ebae-88e0-4130-857f-eb85b0fb24c5.png</url>
      <title>DEV Community: Kyoko Otonashi</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>dvh Is Not a Keyboard Fix: Picking the Right Viewport Tool on Mobile</title>
      <dc:creator>Kyoko Otonashi</dc:creator>
      <pubDate>Fri, 13 Nov 2026 10:01:16 +0000</pubDate>
      <link>https://dev.to/deanliu/dvh-is-not-a-keyboard-fix-picking-the-right-viewport-tool-on-mobile-446o</link>
      <guid>https://dev.to/deanliu/dvh-is-not-a-keyboard-fix-picking-the-right-viewport-tool-on-mobile-446o</guid>
      <description>&lt;p&gt;&lt;code&gt;100dvh&lt;/code&gt; fixed the old &lt;code&gt;100vh&lt;/code&gt; address-bar problem, and a lot of us quietly started treating it as "the mobile viewport fix." It isn't. It fixes &lt;strong&gt;one&lt;/strong&gt; of the things that make a mobile web app's layout jump around. The on-screen keyboard is a different problem, and on iOS Safari &lt;code&gt;dvh&lt;/code&gt; doesn't react to it at all.&lt;/p&gt;

&lt;p&gt;This post is a decision table: which tool handles which mobile layout problem, and where it's easy to reach for the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four problems that look like one
&lt;/h2&gt;

&lt;p&gt;When a full-height mobile layout breaks, it's usually one of these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Browser chrome&lt;/strong&gt; — the address bar and toolbar expand and collapse as you scroll.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The soft keyboard&lt;/strong&gt; — it slides up and covers the bottom part of the screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe areas&lt;/strong&gt; — the notch, the rounded corners, the home indicator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overlays that must stay reachable&lt;/strong&gt; — bottom sheets, sticky toolbars, "Send" buttons.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They all show up as "my bottom bar is in the wrong place," so it's tempting to throw one fix at all of them. They need different tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  What svh, lvh and dvh actually track
&lt;/h2&gt;

&lt;p&gt;The viewport units are about &lt;strong&gt;browser chrome only&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;svh&lt;/code&gt; — height when the browser UI is fully expanded (the &lt;em&gt;small&lt;/em&gt; viewport)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lvh&lt;/code&gt; — height when the browser UI is retracted (the &lt;em&gt;large&lt;/em&gt; viewport)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dvh&lt;/code&gt; — whatever it is &lt;em&gt;right now&lt;/em&gt;, updating as the UI expands and collapses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. None of them are defined in terms of the keyboard.&lt;/p&gt;

&lt;p&gt;On iOS Safari, opening the keyboard doesn't resize the layout viewport. The keyboard overlays the page and only the &lt;strong&gt;visual&lt;/strong&gt; viewport shrinks. So &lt;code&gt;100dvh&lt;/code&gt; stays the same height while the bottom of your layout is now behind the keyboard.&lt;/p&gt;

&lt;p&gt;Chrome on Android behaves the same way by default these days (the keyboard resizes the visual viewport, not the layout viewport). There you can opt into the old "resize everything" behavior with the &lt;code&gt;interactive-widget&lt;/code&gt; key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1, interactive-widget=resizes-content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Safari doesn't support &lt;code&gt;interactive-widget&lt;/code&gt;, so you can't rely on it for iOS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Address bar / toolbar changing height&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;svh&lt;/code&gt; / &lt;code&gt;dvh&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;svh&lt;/code&gt; as a stable floor, &lt;code&gt;dvh&lt;/code&gt; when the layout should follow the chrome&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Soft keyboard covering content&lt;/td&gt;
&lt;td&gt;&lt;code&gt;window.visualViewport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The only cross-browser signal that includes the keyboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notch / home indicator&lt;/td&gt;
&lt;td&gt;&lt;code&gt;env(safe-area-inset-*)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Needs &lt;code&gt;viewport-fit=cover&lt;/code&gt; in the viewport meta&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bottom sheet or toolbar that must sit above the keyboard&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;visualViewport&lt;/code&gt; + a CSS variable&lt;/td&gt;
&lt;td&gt;Only for the components that need it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyboard geometry in Chromium&lt;/td&gt;
&lt;td&gt;VirtualKeyboard API&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;env(keyboard-inset-height)&lt;/code&gt;; not available in Safari&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A sane CSS default
&lt;/h2&gt;

&lt;p&gt;For the app shell, start with plain CSS and no JavaScript:&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;.app-shell&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="mi"&gt;100svh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="cm"&gt;/* stable floor */&lt;/span&gt;
  &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100dvh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="cm"&gt;/* follow browser chrome where supported */&lt;/span&gt;
  &lt;span class="nl"&gt;padding-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;safe-area-inset-bottom&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1, viewport-fit=cover"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of choices worth making on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;height&lt;/code&gt; vs &lt;code&gt;min-height&lt;/code&gt;.&lt;/strong&gt; For document-like screens (articles, settings), &lt;code&gt;min-height&lt;/code&gt; lets content grow. For a viewport-bound app UI (an editor with fixed chrome, a chat screen), you usually want a strict &lt;code&gt;height&lt;/code&gt; and an inner scroll container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dvh&lt;/code&gt; everywhere has a cost.&lt;/strong&gt; &lt;code&gt;dvh&lt;/code&gt; changes while the browser UI animates during scroll, so every element sized with it relayouts during that animation. Use it for the shell, not for every card.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The keyboard: visualViewport, scoped
&lt;/h2&gt;

&lt;p&gt;When something genuinely has to stay above the keyboard, measure the visual viewport and expose it to CSS:&lt;br&gt;
&lt;/p&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="n"&gt;vv&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="n"&gt;visualViewport&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateKeyboardInset&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;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;vv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// How much of the layout viewport is hidden below the visual viewport.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;hidden&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="n"&gt;innerHeight&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;vv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;vv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;offsetTop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'--keyboard-inset'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sb"&gt;`${Math.max(0, hidden)}px`&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;span class="n"&gt;vv&lt;/span&gt;&lt;span class="o"&gt;?&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="s1"&gt;'resize'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updateKeyboardInset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;vv&lt;/span&gt;&lt;span class="o"&gt;?&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="s1"&gt;'scroll'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updateKeyboardInset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;updateKeyboardInset&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.composer-toolbar&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="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;--keyboard-inset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0px&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;Treat the number as an approximation. It moves during the keyboard animation, pinch-zoom changes it too, and the exact values differ between a Safari tab and a home-screen web app. Keep this logic to the one or two components that really need it instead of driving the whole layout from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case dvh and visualViewport both miss
&lt;/h2&gt;

&lt;p&gt;There's one more iOS behavior worth knowing: with the keyboard open, Safari can scroll the &lt;strong&gt;document&lt;/strong&gt; itself, and &lt;code&gt;position: fixed&lt;/code&gt; elements move with it. Whether that happens often depends on something boring — whether your inner scroll container still has room to scroll. If it doesn't, the gesture falls through to the document.&lt;/p&gt;

&lt;p&gt;I wrote up that one separately, with a one-line CSS fix: &lt;a href="https://dev.to/deanliu/the-ios-safari-keyboard-scroll-bug-fixed-with-one-line-of-css-1353"&gt;The iOS Safari keyboard scroll bug, fixed with one line of CSS&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real-device test pass
&lt;/h2&gt;

&lt;p&gt;Simulators are fine for a first look, but keyboard and safe-area behavior needs a real phone. A quick pass that catches most of these bugs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Load the screen, scroll so the browser UI collapses, then expands again.&lt;/li&gt;
&lt;li&gt;Focus the bottom-most input. Is it visible? Is the toolbar still reachable?&lt;/li&gt;
&lt;li&gt;With the keyboard open, try scrolling short content and long content.&lt;/li&gt;
&lt;li&gt;Rotate the device with the keyboard open.&lt;/li&gt;
&lt;li&gt;Repeat in a Safari tab &lt;strong&gt;and&lt;/strong&gt; as a home-screen web app.&lt;/li&gt;
&lt;li&gt;Check the bottom edge against the home indicator.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;&lt;code&gt;dvh&lt;/code&gt; is the right tool for the address bar and nothing else. For the keyboard, reach for &lt;code&gt;visualViewport&lt;/code&gt;, and only in the components that need it. For the notch, &lt;code&gt;env(safe-area-inset-*)&lt;/code&gt;. Most "mobile viewport" bugs get easier once you figure out which of the four problems you're actually looking at.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I deal with this while building &lt;a href="https://penpage.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=dvh-keyboard" rel="noopener noreferrer"&gt;PenPage&lt;/a&gt;, a free, local-first Markdown notes app that runs in the browser. Notes stay on your device. Disclosure: it's my project.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>ios</category>
      <category>mobile</category>
    </item>
    <item>
      <title>The iOS Safari keyboard scroll bug, fixed with one line of CSS</title>
      <dc:creator>Kyoko Otonashi</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>
