Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Mobile UI in React
  4. iOS Safari Keyboard Position Fixed: Keep a Composer Visible
Auf Deutsch lesen
  1. Blog
  2. Mobile UI in React
  3. iOS Safari Keyboard Position Fixed: Keep a Composer Visible
Mobile UI in React
Mobile UI in React

iOS Safari Keyboard Position Fixed: Keep a Composer Visible

SR
Serban Rusu · Founder of Wingo UI
Oct 13, 2026 · 9 min read

On this page

0%
  1. Why does the keyboard cover position fixed elements on iOS?
  2. Can CSS alone keep a bar above the iOS keyboard?
  3. How do you measure the keyboard with visualViewport on iOS?
  4. Why move the bar with a motion value instead of state?
  5. What does the useKeyboardInset hook add?
  6. How do you keep the last message visible above a sticky composer?
  7. What about a bottom sheet with a text field?
  8. Which iOS keyboard bugs should you test for?
  9. Where should you start?
  10. FAQ
    1. Why does position fixed not work with the keyboard on iOS Safari?
    2. Does 100dvh change when the iOS keyboard opens?
    3. Does interactive-widget=resizes-content work on iPhone?
    4. Can I use the VirtualKeyboard API and keyboard-inset-height on iOS?
    5. Why is my fixed header offset after the keyboard closes on iOS 26?

TL;DR

On an iPhone the on-screen keyboard shrinks only the visual viewport, so an element with position: fixed; bottom: 0 stays at the bottom of the layout viewport, behind the keyboard. No CSS unit or viewport setting changes that in Safari as of October 2026, so measure the covered height as innerHeight minus visualViewport.height minus visualViewport.offsetTop while a text field has focus, and move the bar up by that amount with a transform. Drive the transform from a motion value so the bar follows the keyboard without re-rendering React.

Published Oct 13, 2026

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:

ValueMeaning
window.innerHeightThe layout viewport's height, unchanged by the keyboard
visualViewport.heightThe visible height above the keyboard
visualViewport.offsetTopHow far Safari panned the visible part down
innerHeight - height - offsetTopThe part of the layout viewport the keyboard covers

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, svh and lvh follow the layout viewport, so none of them shrinks for the keyboard. The iOS Safari keyboard viewport height you want is visualViewport.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 and dvh move with it. In Next.js it is interactiveWidget: "resizes-content" in the viewport export. 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, so dvh layouts reflow and anything sized to the viewport jumps.
  • The VirtualKeyboard API. With navigator.virtualKeyboard.overlaysContent = true, CSS can read env(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:

tsx
// hooks/use-keyboard-height.ts
"use client";
import { useEffect } from "react";
import { useMotionValue, type MotionValue } from "motion/react";
const NO_KEYBOARD = /^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/;
function isTextField(element: Element | null) {
if (element instanceof HTMLTextAreaElement) return !element.readOnly && !element.disabled;
if (element instanceof HTMLInputElement) return !NO_KEYBOARD.test(element.type) && !element.readOnly && !element.disabled;
return element instanceof HTMLElement && element.isContentEditable;
}
// px of the layout viewport the on-screen keyboard covers, 0 when it is closed
export function useKeyboardHeight(threshold = 80): MotionValue<number> {
const height = useMotionValue(0);
useEffect(() => {
const viewport = window.visualViewport;
if (!viewport) return;
let frame = 0;
const measure = () => {
frame = 0;
const covered = window.innerHeight - viewport.height - viewport.offsetTop;
const typing = isTextField(document.activeElement) && viewport.scale <= 1.01;
height.set(typing && covered > threshold ? Math.round(covered) : 0);
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(measure);
};
viewport.addEventListener("resize", schedule);
viewport.addEventListener("scroll", schedule);
document.addEventListener("focusin", schedule);
document.addEventListener("focusout", schedule);
measure();
return () => {
cancelAnimationFrame(frame);
viewport.removeEventListener("resize", schedule);
viewport.removeEventListener("scroll", schedule);
document.removeEventListener("focusin", schedule);
document.removeEventListener("focusout", schedule);
};
}, [height, threshold]);
return height;
}

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 no window on 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.

tsx
// components/composer.tsx
"use client";
import { motion, useTransform } from "motion/react";
import { useKeyboardHeight } from "@/hooks/use-keyboard-height";
export function Composer() {
const keyboard = useKeyboardHeight();
const y = useTransform(keyboard, (value) => -value);
return (
<motion.form
style={{ y }}
className="fixed inset-x-0 bottom-0 z-40 flex gap-2 border-t border-border bg-background px-3 pt-2 pb-[max(env(safe-area-inset-bottom),12px)]"
>
<input
aria-label="Message"
placeholder="Write a message"
enterKeyHint="send"
className="h-11 min-w-0 flex-1 rounded-full bg-foreground/5 px-4 text-base"
/>
</motion.form>
);
}

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.

tsx
"use client";
import { motion, useTransform } from "motion/react";
import { useKeyboardInset } from "@/hooks/use-keyboard-inset";
import { cn } from "@/lib/utils";
export function Composer() {
const { inset, open } = useKeyboardInset();
const y = useTransform(inset, (value) => -value);
return (
<motion.form
style={{ y }}
className={cn(
"fixed inset-x-0 bottom-0 z-40 flex gap-2 border-t border-border bg-background px-3 pt-2",
open ? "pb-2" : "pb-[max(env(safe-area-inset-bottom),12px)]",
)}
>
<input aria-label="Message" placeholder="Write a message" enterKeyHint="send" className="h-11 min-w-0 flex-1 rounded-full bg-foreground/5 px-4 text-base" />
</motion.form>
);
}
On a phone, tap the message field: the composer rides on top of the keyboard while the readout shows the covered height and the open flag. On a desktop there is no on-screen keyboard, so the inset stays at 0px
$ npx wingo-ui@latest add use-keyboard-inset
ProuseKeyboardInset docs

Install 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:

tsx
// app/chat/chat-screen.tsx
"use client";
import { useEffect, useState } from "react";
import { PromptInput, type PromptInputMessage } from "@/components/ui/prompt-input";
import { useKeyboardInset } from "@/hooks/use-keyboard-inset";
type Message = { id: string; role: "user" | "assistant"; text: string };
export function ChatScreen({ ask }: { ask: (text: string) => Promise<string> }) {
const [messages, setMessages] = useState<Message[]>([]);
// writes the covered height to --keyboard-inset for the padding below
const { open } = useKeyboardInset({ cssVar: "--keyboard-inset" });
useEffect(() => {
if (open) window.scrollTo({ top: document.documentElement.scrollHeight });
}, [open, messages.length]);
async function send({ text }: PromptInputMessage) {
setMessages((list) => [...list, { id: crypto.randomUUID(), role: "user", text }]);
const reply = await ask(text);
setMessages((list) => [...list, { id: crypto.randomUUID(), role: "assistant", text: reply }]);
}
return (
<>
<ol className="flex flex-col gap-3 px-4 pt-4 pb-[calc(var(--prompt-input-offset,88px)+var(--keyboard-inset,0px)+16px)]">
{messages.map((message) => (
<li key={message.id} className={message.role === "user" ? "self-end rounded-2xl bg-foreground/10 px-4 py-2" : "self-start"}>
{message.text}
</li>
))}
</ol>
<PromptInput position="fixed" onSubmit={send} placeholder="Message the assistant" />
</>
);
}

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.

Type a few lines: the box grows up to eight rows, then scrolls. Press + for attachments and the tool chips, and send with the round button
$ npx wingo-ui@latest add prompt-input
ProPrompt Input docs

What 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.

tsx
import { Button } from "@/components/ui/button";
import { Drawer } from "@/components/ui/drawer";
export function RenameSheet() {
return (
<Drawer trigger={<Button variant="soft">Rename</Button>} title="Rename listing" footer={<Button size="lg" fullWidth>Save</Button>}>
<input aria-label="Listing name" defaultValue="2-bedroom apartment, Brooklyn" className="h-11 w-full rounded-xl bg-foreground/5 px-4 text-base" />
</Drawer>
);
}
Tap Filters, then drag the sheet down by its handle to dismiss it. On a phone, a focused field inside a sheet lifts it above the keyboard
$ npx wingo-ui@latest add drawer
FreeDrawer docs

Turn 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.offsetTop the 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 focusout and the bar returns to the bottom even when the viewport numbers lag behind the closing keyboard. A version that reads only the resize event 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.offsetTop above 0, or the height short of innerHeight, 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 of offsetTop stuck 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.

Components in this post

  • useKeyboardInset

    A React hook for the mobile on-screen keyboard: how much of the screen it covers, so bars pinned to the bottom ride above it.

    Pro
  • Prompt Input

    A React prompt input for AI chat: a growing text box with attachments, tool chips, a model picker, dictation and a send and stop button.

    Pro
  • Drawer

    A React drawer and bottom sheet built on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop.

    Free

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

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

100vh Mobile Fix: When to Use dvh, svh or lvh in Tailwind

The 100vh mobile bug explained: why h-screen hides your bottom bar, when to use dvh, svh or lvh in Tailwind, and how to keep a bar above the keyboard.

SRSerban Rusu·Oct 9, 2026·11 min read
Mobile UI in React

React Action Sheet: iOS-Style, Async and Responsive

Build a React action sheet like iOS: red destructive rows, a separate Cancel, an awaitable show() that waits for async actions, and a dialog from 768px.

SRSerban Rusu·Oct 16, 2026·9 min read
Mobile UI in React

Tailwind Touch Manipulation: touch-action for Every Control

Tailwind touch manipulation explained: which touch-action utility each button, slider, swipe row and sheet needs, and the tap and zoom bugs each value fixes.

SRSerban Rusu·Oct 13, 2026·9 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI