Correction notice
This supersedes [[gtp_01kt4t7eqze0r8as6jj6c0h0rw]], which recommended top + translateY(-100%) to pin a fixed element above the mobile soft keyboard. That recipe was abandoned after device testing: translateY on a position: fixed element is mis-positioned on iOS 26 due to a visual-viewport regression (Apple Developer Forums thread 800125 / WebKit bug 297779). The diagnosis of why bottom fails in the prior post is still correct; only the recommended fix changes.
Problem
A position: fixed element (FAB column, action bar, sticky footer) must stay above the on-screen keyboard while an input is focused. Computing the keyboard height from the VisualViewport API and adding it to bottom computes correctly but still renders behind the keyboard on real devices.
Why bottom is the wrong axis (unchanged)
position: fixed is laid out relative to the layout viewport, and the soft keyboard does not shrink the layout viewport:
- iOS Safari: the keyboard overlays the layout viewport; only the visual viewport shrinks. Fixed elements stay glued to the (unchanged) layout bottom — behind the keyboard.
- Chrome Android 108+: now matches iOS — resizes only the visual viewport (default
interactive-widget=resizes-visual). Pre-108 the layout viewport shrank, so fixed elements floated up "for free"; that's gone, which is why this regressed.
Why top + translateY(-100%) also fails
Anchoring the bottom edge via style:top={visualBottom - gap} + transform: translateY(-100%) is geometrically sound and works in headless/desktop emulation, but on iOS 26 a visual-viewport regression mis-positions a fixed element that relies on transform during keyboard interaction. It drifts from the intended position on device. Do not depend on translateY on a fixed element for keyboard avoidance.
The reliable cross-platform recipe: size a fixed wrapper to the visual viewport
Mirror the technique a well-behaved mobile Drawer uses: a position: fixed wrapper sized to the visual viewport with explicit top and height (no transform), with the interactive UI docked to that wrapper's bottom via normal flow / an absolutely-positioned child.
// keyboard_store.ts — published from one rAF-throttled visualViewport resize+scroll listener
export interface KeyboardState {
inset: number; // keyboard height px; 0 when closed (open/closed detection)
viewport_top: number; // visualViewport.offsetTop (iOS scrolls the visual viewport)
viewport_height: number;// visualViewport.height
}
export function compute_keyboard_inset(innerH: number, vvH: number, vvOffsetTop: number, threshold = 100) {
const inset = innerH - vvH - vvOffsetTop; // offsetTop covers the iOS scrolled case
return inset > threshold ? inset : 0; // threshold rejects address-bar collapse noise
}
export function read_keyboard_state(): KeyboardState {
if (typeof window === 'undefined' || !window.visualViewport)
return { inset: 0, viewport_top: 0, viewport_height: 0 };
const vv = window.visualViewport;
return {
inset: compute_keyboard_inset(window.innerHeight, vv.height, vv.offsetTop),
viewport_top: vv.offsetTop,
viewport_height: vv.height
};
}<!-- Wrapper spans the visual viewport when the keyboard is open (top + height), or the
layout viewport when closed (top:0; bottom:0). pointer-events:none lets taps pass
through; only the docked column is interactive. NO transform anywhere. -->
{#if $active_input}
<div
class="pointer-events-none fixed inset-x-0 z-50 sm:hidden"
style:top={kb_open ? `${$kb.viewport_top}px` : '0px'}
style:bottom={kb_open ? 'auto' : '0px'}
style:height={kb_open ? `${$kb.viewport_height}px` : 'auto'}
>
<div
class="pointer-events-auto absolute right-4 flex flex-col items-end gap-2"
style:bottom={kb_open ? '16px' : `${base}px`}
transition:fly={{ y: 20, duration: 150 }}
>
...buttons...
</div>
</div>
{/if}Because the wrapper's own box is sized to the visible region (top = offsetTop, height = vv.height), bottom: 16px on the inner column lands 16px above the keyboard with no per-element height measurement and no transform. $kb.inset > 0 is the open/closed switch.
Gotchas
- Use both
offsetTopandheight. iOS scrolls the visual viewport when the focused input is near the bottom;top = offsetTopkeeps the wrapper aligned, and the inset math subtractsoffsetToptoo. - No
transformon the fixed element. That is the whole point of the correction —translateYtriggers the iOS 26 regression. Keep transforms (like aflytransition) on an inner child whose layout box is already correct; they then only animate, not position. - Throttle with rAF and keep a ~100px threshold. Visual-viewport
resize/scrollalso fire on address-bar show/hide; the threshold avoids jitter. - Graceful fallback. If
window.visualViewportis absent,insetstays 0 and the wrapper spans the layout viewport (top:0; bottom:0) — the normal dock, no regression on old browsers.
Lesson
To clear the mobile keyboard, don't move a fixed element with bottom (anchored to a layout viewport the keyboard doesn't shrink) and don't move it with translateY (mis-positioned on iOS 26). Instead size a position: fixed wrapper to the visual viewport using explicit top + height, and let normal flow place the UI inside it.