Skip to content

fix(VOverlay): keep overlay attached with iOS keyboard open - #22923

Merged
J-Sek merged 2 commits into
vuetifyjs:masterfrom
nvgfe001:fix/22376-ios-keyboard-overlay-drift
Jun 13, 2026
Merged

fix(VOverlay): keep overlay attached with iOS keyboard open#22923
J-Sek merged 2 commits into
vuetifyjs:masterfrom
nvgfe001:fix/22376-ios-keyboard-overlay-drift

Conversation

@nvgfe001

Copy link
Copy Markdown
Contributor

Description

With the iOS/iPadOS software keyboard open, VMenu, VAutocomplete, VCombobox and VSelect dropdowns
drift off their activator as you scroll: they freeze, lag, or appear to scroll opposite the page,
and the keyboard can end up covering the options. Reproduces on v4.1.1.

The cause is a mixed coordinate basis in connectedLocationStrategy. With the keyboard up,
getElementBox(document.documentElement) adds visualViewport.offsetTop to the overflow-clamp
viewport, while the activator targetBox comes straight from getBoundingClientRect and stays
in the layout frame. The two boxes diverge by exactly offsetTop, the clamp top lands below the
activator, and the overflow-shift logic pins the content there while the activator scrolls away.

The clamp origin should match the frame targetBox lives in. On iOS WebKit the layout viewport
itself slides under the keyboard, so that origin is 0, not offsetTop. The fix gates the
existing ternary in getElementBox()'s documentElement branch on the engine:

 // util/box.ts — getElementBox(), documentElement branch
-        x: visualViewport.scale > 1 ? 0 : visualViewport.offsetLeft,
-        y: visualViewport.scale > 1 ? 0 : visualViewport.offsetTop,
+        x: visualViewport.scale > 1 || IS_WEBKIT ? 0 : visualViewport.offsetLeft,
+        y: visualViewport.scale > 1 || IS_WEBKIT ? 0 : visualViewport.offsetTop,

 // util/globals.ts
+export const IS_WEBKIT = IN_BROWSER && typeof CSS !== 'undefined' && typeof CSS.supports !== 'undefined' &&
+  CSS.supports('-webkit-backdrop-filter', 'none')

An unconditional x: 0, y: 0 would regress Android/Blink: the layout viewport does not move
there, so offsetTop is the correct origin (what #21462 needed). With the gate, the Blink branch
evaluates exactly as today; only iOS WebKit changes. Numeric traces below are from an iPad Air
13" M4 simulator (iPadOS 26.5); the fix was additionally verified on a physical iPad Air 11-inch
(M4), where the overlay stays anchored with the keyboard open.

fixes #22376

On-device evidence

iPad Air 13" M4 simulator, iPadOS 26.5, real WebKit, software keyboard up, scale === 1,
visualViewport.offsetTop = 403 (plus the behavioral pass on the physical iPad Air 11" M4 above).
Internal updateLocation() values and painted positions, traced before and after the fix:

Metric (keyboard up, activator on-screen) current (offsetTop) with origin 0
internal clamp viewport.top 415 (offsetTop + margin) 12 (0 + margin)
VMenu (#22376) drift, mean / max 138 / 409 px 0 / 1.9 px
VAutocomplete (#22490) drift 204 px -0.05 px
stickToTarget edge-clipping on-screen still on-screen

A separate A/B run isolated three clamp-origin policies on Case A (VMenu + text field), keyboard
up, after a single small scroll with the button still visible (scrollY ~ 762, offsetTop ~ 403):

clamp-origin policy drift (menu vs activator) result
keep offsetTop (current master) 467 px menu detaches, the bug
zero on WebKit (this PR) 28 px menu stays anchored
Floating-UI-faithful (offset only if strategy fixed) 29 px identical to zeroing

The third row matters: a faithful Floating UI carve-out behaves identically to plain zeroing,
because Vuetify's connected overlay is position: absolute (the trace's .v-overlay__content
reports absolute), so the fixed-strategy branch never fires.

I have only measured iOS — there is no separate Android trace here. There is also no automated
test: jsdom stubs visualViewport as an empty EventTarget and headless browsers don't drive the
keyboard, which is why recent VOverlay positioning fixes merged without tests.

Why the ternary has to stay (commit history)

The scale > 1 ? 0 : offsetLeft/offsetTop ternary stacks three fixes; replacing it with a
constant would unwind the first:

Commit Author Message Issue / PR
6523eee4d KaelWD fix(VMenu): position relative to visualViewport when not zoomed #21462
32242a353 J-Sek feat(locationStrategies): support CSS zoom #21878 / #20719
3085a93e4 J-Sek fix(VOverlay): keep content attached to the activator on page with zoom #22326

#21462 was an Android Chrome keyboard bug. Its scroll-chaining symptom was cured by the clamp
height (visible-region height feeding the dropdown max-height), which this change keeps; correct
placement on Android still needs origin and size in one consistent layout frame, i.e. origin
offsetTop. An unconditional 0 would introduce a new Android positioning error in the same
keyboard-open scenario, so the branch is gated rather than removed.

Floating UI precedent

CSS.supports('-webkit-backdrop-filter', 'none') is Floating UI's isWebKit(), shipped today by
Radix, Element Plus and other @floating-ui/dom consumers. It catches iOS Chrome and iOS Firefox
(also WebKit, same keyboard behaviour), returns false on Blink (Chromium shipped
backdrop-filter unprefixed in 76 and never aliased the -webkit- form), and is a capability
probe in the same CSS.supports guard shape as the supportsSelector const inside
matchesSelector() in helpers.ts.

The offset policy here is Vuetify's own frame-symmetry argument — getOverflow(content, viewport)
is only meaningful if both operands share a frame, and targetBox/contentBox are raw
getBoundingClientRect (layout frame) — but it agrees with Floating UI:
getViewportRect(el, strategy)
adds visualViewport.offset{Left,Top} only when !isWebKit() || strategy === 'fixed', so for
Vuetify's position: absolute connected overlay its rule also zeroes on WebKit.

Alternatives considered & residual risks
  • A position: fixed; top: 0 probe to infer the engine: a transform, filter, contain or
    will-change on an ancestor makes the probe resolve against that ancestor (the containing-block
    trap — Floating UI ships a getContainingBlock() traversal because of it). The CSS.supports
    probe reads an engine constant and touches no layout.
  • An unconditional x: 0, y: 0: the platform swap covered in the description and commit history
    above.
  • Branching on scale, or UA sniffing: the real axis is the painted frame (engine), and UA
    strings miss iOS Chrome/Firefox.
  • Desktop Safari is also IS_WEBKIT, but the change is observable only when scale === 1 and
    offsetTop/offsetLeft is non-zero — a panned visual viewport, dominantly the soft keyboard.
  • Modern Android (interactive-widget=resizes-visual by default) may itself want origin 0; the
    gate deliberately preserves today's Blink behaviour pending a real-device measurement, so any
    such drift is pre-existing and unchanged by this PR.
  • Like any feature detect, IS_WEBKIT would misfire if a future Blink aliased the -webkit-
    form; it does not today (checked against css_properties.json5, MDN BCD and caniuse).
  • Unaffected cases: keyboard down (offsetTop = 0), pinch-zoom (scale > 1 already
    returned 0) and CSS body zoom ([Bug Report][3.7.4] Incorrect Tooltip Positioning When Body's Zoom is Set to 0.7 #20719 / [Bug Report][3.10.10] Vuetify is miscalculating v-overlay__content position if body element have zoom property. #22326 ride currentCSSZoom and width/height).
    Only the x/y origin of getElementBox(documentElement) changes; width/height and the %
    size resolver are untouched.

Repro notes: this does not show in play.vuetifyjs.com (its iframe masks the visualViewport
offset) — use a real device or the iOS Simulator with the software keyboard (Cmd+Shift+K), and
tap the field to raise the keyboard.

Markup:

<template>
  <v-container>
    <div style="height: 45vh" />

    <!-- Case A, #22376: VMenu with a text field -->
    <v-menu :close-on-content-click="false">
      <template #activator="{ props }">
        <v-btn v-bind="props" color="primary" block class="mb-6">
          Case A: VMenu + text field
        </v-btn>
      </template>
      <v-card width="300">
        <v-card-text>
          <!-- tap the field; autofocus alone does not raise the iOS keyboard -->
          <v-text-field label="Type here, watch the menu move" autofocus />
        </v-card-text>
      </v-card>
    </v-menu>

    <!-- Case B, #22490: VAutocomplete -->
    <v-autocomplete
      :items="items"
      label="Case B: tap, then scroll while the keyboard is open"
    />

    <div style="height: 150vh" />
  </v-container>
</template>

<script setup>
  const items = Array.from({ length: 30 }, (_, i) => `Option ${i + 1}`)
</script>

nvgfe001 and others added 2 commits June 13, 2026 12:49
With the software keyboard open, getElementBox()'s documentElement
branch adds visualViewport.offsetTop to the overflow-clamp viewport,
while the activator box stays in the layout frame from
getBoundingClientRect. The two frames diverge by offsetTop and the
overlay freezes off its activator on scroll.

The correct clamp origin is the visual-viewport top in the layout
frame: offsetTop on Blink, but 0 on iOS WebKit, where the layout
viewport itself slides under the keyboard. Gate the origin on
IS_WEBKIT so WebKit uses 0 and Blink keeps offsetTop. scale > 1
still returns 0.

fixes vuetifyjs#22376
@J-Sek
J-Sek force-pushed the fix/22376-ios-keyboard-overlay-drift branch from 8125f6c to a8616c6 Compare June 13, 2026 10:50
@J-Sek
J-Sek merged commit 2e51298 into vuetifyjs:master Jun 13, 2026
4 checks passed
@J-Sek J-Sek added T: bug Functionality that does not work as intended/expected platform specific The issue only occurs on a specific platform C: VOverlay labels Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C: VOverlay platform specific The issue only occurs on a specific platform T: bug Functionality that does not work as intended/expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug Report][3.11.0] Typing with virtual keyboard in iPad causing VMenu position change.

2 participants