# iOS Safari Keyboard Position Fixed: Keep a Composer Visible

> Fix the iOS Safari keyboard position fixed bug: measure the keyboard with visualViewport and lift a chat composer or sheet above it with a motion value.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 13, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/ios-safari-keyboard-position-fixed

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

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](https://wingo-ui.com/blog/category/mobile-ui) series. The guide to [100vh on mobile](https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind) 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](https://developer.chrome.com/blog/viewport-resize-behavior)). 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:

| Value | Meaning |
|---|---|
| `window.innerHeight` | The layout viewport's height, unchanged by the keyboard |
| `visualViewport.height` | The visible height above the keyboard |
| `visualViewport.offsetTop` | How far Safari panned the visible part down |
| `innerHeight - height - offsetTop` | The 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](https://caniuse.com/mdn-html_elements_meta_name_viewport_interactive-widget) 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](https://developer.mozilla.org/en-US/docs/Web/API/VirtualKeyboard_API) marks it experimental, and [caniuse](https://caniuse.com/mdn-api_virtualkeyboard) 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](https://wingo-ui.com/blog/tailwind-safe-area-insets) explains the `max()`.

## What does the useKeyboardInset hook add?

The Wingo UI [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) 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>
  );
}
```

> Live demo (useKeyboardInset): 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. Try it at [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) and install it with `npx wingo-ui@latest add use-keyboard-inset`.

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](https://wingo-ui.com/components/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.

> Live demo (Prompt Input): 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. Try it at [Prompt Input](https://wingo-ui.com/components/prompt-input) and install it with `npx wingo-ui@latest add prompt-input`.

## 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](https://wingo-ui.com/components/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](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web) 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>
  );
}
```

> Live demo (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. Try it at [Drawer](https://wingo-ui.com/components/drawer) and install it with `npx wingo-ui@latest add drawer`.

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](https://developer.apple.com/forums/thread/800125) 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](https://bugs.webkit.org/show_bug.cgi?id=297779), 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](https://wingo-ui.com/components/prompt-input) with `position="fixed"` does the lifting, the safe area and the height variable for you, and the [React AI chat UI guide](https://wingo-ui.com/blog/react-ai-chat-ui) covers the rest of the screen. useKeyboardInset and the Prompt Input are part of [Wingo UI Pro](https://wingo-ui.com/pricing); the Drawer is free with `npx wingo-ui@latest add drawer`.

## Components in this post

- [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) (Pro): 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. Install: `npx wingo-ui@latest add use-keyboard-inset`
- [Prompt Input](https://wingo-ui.com/components/prompt-input) (Pro): 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. Install: `npx wingo-ui@latest add prompt-input`
- [Drawer](https://wingo-ui.com/components/drawer) (Free): A React drawer and bottom sheet built on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: `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.

---

Source: https://wingo-ui.com/blog/ios-safari-keyboard-position-fixed
