iOS Safari Keyboard Position Fixed: Keep a Composer Visible

You pin a chat composer to the bottom of the screen with position: fixed; bottom: 0, tap the text field on an iPhone, and the keyboard slides up over it. You type blind, or Safari scrolls the page so the field peeks out above the keys and the header slides off the top. The iOS Safari keyboard position fixed problem is not a bug in your CSS: the keyboard shrinks a different viewport than the one fixed elements use. This tutorial explains the two viewports, shows why CSS alone cannot fix it on iOS today, and builds a composer that rides on top of the keyboard with visualViewport and a motion value.
It is part of our Mobile UI in React series. The guide to 100vh on mobile covers the viewport units this post builds on.
Why does the keyboard cover position fixed elements on iOS?
Because the page has two viewports and the keyboard resizes only one. The layout viewport is the box your CSS lays out against: position: fixed, 100dvh and window.innerHeight all refer to it. The visual viewport is the part of the page you can actually see, which shrinks when you pinch zoom or when the keyboard opens.
Safari on iOS keeps the layout viewport at full height when the keyboard appears and shrinks only the visual viewport. Chrome for Android switched to the same behavior in version 108 (Chrome's announcement (opens in a new tab)). So an element at bottom: 0 stays at the bottom of the layout viewport, which is now behind the keyboard.
Safari adds a second effect. To keep the focused field visible, it pans the visual viewport down inside the layout viewport, so visualViewport.offsetTop grows above 0. That pan is what pushes a fixed header off the top of the screen while you type.
While a field has focus, these are the numbers you can read:
That last line is the whole fix: move the bar up by it.
Can CSS alone keep a bar above the iOS keyboard?
Not in Safari, as of October 2026. When the keyboard covers the input, most answers online point to one of three CSS-side tools. Each of them skips iPhones:
- Viewport units.
dvh,svhandlvhfollow the layout viewport, so none of them shrinks for the keyboard. The iOS Safari keyboard viewport height you want isvisualViewport.height, which only JavaScript can read. interactive-widget=resizes-content. This key of the viewport meta tag makes the keyboard resize the layout viewport, so fixed bars anddvhmove with it. In Next.js it isinteractiveWidget: "resizes-content"in theviewportexport. caniuse (opens in a new tab) lists Chrome and Firefox for Android, and no version of Safari on iOS. It has a cost: every keyboard open now resizes the page, sodvhlayouts reflow and anything sized to the viewport jumps.- The VirtualKeyboard API. With
navigator.virtualKeyboard.overlaysContent = true, CSS can readenv(keyboard-inset-height). MDN (opens in a new tab) marks it experimental, and caniuse (opens in a new tab) shows it in Chromium browsers only, not in Safari or Firefox.
Setting interactiveWidget can still make sense if most of your traffic is Android, but an iPhone needs code. That code also works on Android with the default setting, so one path covers both. The two do not conflict either: with resizes-content, Android shrinks innerHeight along with the visual viewport, so the measurement below reads 0 there and nothing gets lifted twice.
How do you measure the keyboard with visualViewport on iOS?
Listen to the visual viewport's resize and scroll events and to focus changes, read the three numbers once per animation frame, and only count the result while a text field has focus. The focus check matters: a pinch zoom or the browser toolbar collapsing also changes these numbers, and neither should lift your composer. The whole measurement fits in one hook:
A few choices in there are deliberate:
- The 80px threshold. Small changes in the visual viewport are browser chrome, not a keyboard. Anything under the threshold counts as 0.
viewport.scale. While someone is pinch zoomed, the visual viewport is smaller for a reason that has nothing to do with the keyboard.requestAnimationFrame. The keyboard animates, and Safari can fire several events per frame. Measuring once per frame keeps the work to one read and one write.- Everything inside
useEffect. There is nowindowon the server, and the first client render must match the server HTML, so the height starts at 0.
Why move the bar with a motion value instead of state?
Because the value changes on every frame while the keyboard slides, and a setState per frame re-renders the composer and everything under it on each of those frames, up to 60 or 120 times a second depending on the display. A motion value from motion/react skips React: you hand it to style, and Motion writes the transform straight to the element.
translateY moves the bar without a layout, so nothing else on the page reflows while the keyboard moves. The input uses text-base (16px), because Safari on iOS zooms the page into any focused field with smaller text. The bottom padding clears the home indicator when the keyboard is closed; Tailwind safe area insets explains the max().
What does the useKeyboardInset hook add?
The Wingo UI useKeyboardInset hook is the same measurement with two extras: an open flag that re-renders only when the keyboard opens or closes, and an optional cssVar that writes the height to a CSS variable on <html> for layouts that are easier to write in CSS. The open flag fixes a detail the composer above gets wrong: once the keyboard is up, the home indicator is hidden behind it, so the safe area padding becomes a gap between the bar and the keys.
$ npx wingo-ui@latest add use-keyboard-insetInstall it with npx wingo-ui@latest add use-keyboard-inset. It installs motion if your project does not have it.
How do you keep the last message visible above a sticky composer?
Pad the scrolling content by the composer's height plus the keyboard's, and scroll to the end when the keyboard opens. This is the part most sticky composer setups forget: the bar moves up, but the newest message stays where the keyboard now is.
The Prompt Input handles the bar for you. With position="fixed" it pins itself to the bottom, lifts itself above the keyboard through useKeyboardInset without re-rendering, pads the home indicator, and writes its own height to --prompt-input-offset on <html>. Add the keyboard's height as a second variable and the page can pad itself in CSS:
The page scrolls the window here, which keeps Safari's own scrolling, the status bar tap to scroll up, and the toolbar collapsing as you scroll. On a phone, Enter in the Prompt Input makes a new line and the round button sends without closing the keyboard, so people can send several messages in a row.
$ npx wingo-ui@latest add prompt-inputWhat about a bottom sheet with a text field?
A sheet is a fixed element too, so the keyboard covers its footer and any field in its lower half. The free Drawer relies on vaul's repositionInputs, on by default: while a field inside has focus, it raises the sheet by the height the keyboard covers and shrinks it when it no longer fits above the keyboard. Our React bottom sheet guide covers how that interacts with snap points.
$ npx wingo-ui@latest add drawerTurn repositionInputs off only when the sheet handles the keyboard itself, for example a comment sheet whose composer already follows useKeyboardInset. Two systems moving the same sheet fight each other.
Which iOS keyboard bugs should you test for?
The measurement is simple; the edge cases are where apps break. Test these on a real iPhone, or in the iOS Simulator with the hardware keyboard disconnected so the on-screen keyboard shows:
- The header slides away. Safari's pan moves the visual viewport down, so a fixed header leaves the top. If it must stay, move it down by
visualViewport.offsetTopthe same way you move the composer up. - The keyboard closes, the bar stays up. The hook counts the inset only while a text field has focus, so it drops to 0 on
focusoutand the bar returns to the bottom even when the viewport numbers lag behind the closing keyboard. A version that reads only theresizeevent can leave the bar floating. - Offsets that never reset. Developers reported on the Apple Developer Forums (opens in a new tab) that some iOS 26.0 builds left
visualViewport.offsetTopabove 0, or the height short ofinnerHeight, after the keyboard closed, so fixed bars sat off. WebKit bug 297779 (opens in a new tab), filed in August 2025 against an iOS 26 beta, collects the same symptom, including one report ofoffsetTopstuck at 24px; commenters there report it fixed in iOS 26.1 for some pages and still present on others. Recomputing on every event, rather than caching the first value, keeps you on whatever the browser reports now, and the focus check ignores stale numbers once no field has focus. - Pinch zoom. Zoom in, then focus the field. The scale check keeps the bar where it is instead of jumping around.
- Fields that raise no keyboard. A checkbox or a range input inside the composer gets focus without a keyboard; the type list leaves it out.
- Desktop and iPad with a hardware keyboard. No on-screen keyboard means the visual viewport does not shrink, so the inset stays 0 and nothing moves.
You cannot test this in desktop Chrome's device mode, which has no on-screen keyboard. For automated tests, assert the layout at inset 0 and cover the keyboard path with a manual pass on a device before each release.
Where should you start?
If you only have one fixed bar, copy the useKeyboardHeight hook above and give the bar a y transform: it is about 50 lines and has no dependency beyond motion. If you are building a chat screen, the Prompt Input with position="fixed" does the lifting, the safe area and the height variable for you, and the React AI chat UI guide covers the rest of the screen. useKeyboardInset and the Prompt Input are part of Wingo UI Pro; the Drawer is free with npx wingo-ui@latest add drawer.
FAQ
Why does position fixed not work with the keyboard on iOS Safari?
It works, just against the wrong box. Safari keeps the layout viewport at full height when the keyboard opens and shrinks only the visual viewport, and fixed elements are placed against the layout viewport, so bottom: 0 is behind the keyboard.
Does 100dvh change when the iOS keyboard opens?
No. Viewport units follow the layout viewport, which the keyboard does not resize in Safari on iOS, nor by default in Chrome for Android since version 108. Read window.visualViewport for the height you can see.
Does interactive-widget=resizes-content work on iPhone?
No. As of October 2026, caniuse lists support in Chrome and Firefox for Android and none in Safari on iOS, so iPhones still need the visualViewport measurement.
Can I use the VirtualKeyboard API and keyboard-inset-height on iOS?
No. The VirtualKeyboard API and its env(keyboard-inset-*) variables ship in Chromium browsers only; caniuse shows no support in Safari or Firefox as of October 2026.
Why is my fixed header offset after the keyboard closes on iOS 26?
Some iOS 26.0 builds leave visualViewport.offsetTop above 0, or visualViewport.height short of innerHeight, after the keyboard closes, as reported on the Apple Developer Forums and in WebKit bug 297779. Recompute on every visualViewport resize and scroll event instead of caching a value, and ignore the numbers while no text field has focus.
- iOS Safari
- Keyboard
- visualViewport
- Mobile
- React


