Repository: mui/base-ui
Pull request #1735: [Dialog] Allow document to slide input into view on iOS when keyboard opens
Author: login: atomiks
Labels: name: component: dialog
description: Changes related to the dialog component.
Created: 2025-04-17T01:10:19Z | Merged: 2025-04-17T09:51:36Z

Fixes #1455

The way inputs slide inside Popover/anchored elements is a lot stranger so this is just restricted to Dialog, for now

Test on iOS: https://deploy-preview-1735--base-ui.netlify.app/experiments/mobile-scroll-lock

Linked issue #1455: [dialog] Dialogs should adjust position when mobile keyboard is open
# Feature request

## Summary

Dialogs on mobile could be more responsive, particularly in handling input fields. This is not limited to dialogs but applies broadly to modals. The document should adjust its position dynamically to keep the input field visible when the keyboard is open.

Additionally, there's a noticeable jumping effect when the keyboard appears, which affects the user experience. This behavior is demonstrated in the attached video.

I’ve included three examples from Base UI, 9ui (my project built on top of Base UI), and shadcn/ui to illustrate how other libraries handle this.

## Examples in other libraries

https://github.com/user-attachments/assets/95f7dbfd-f401-468f-8d05-17a7c448213e (Base UI)
https://github.com/user-attachments/assets/68efb3b7-431e-4758-82de-2b85c6cc236b (9ui)
https://github.com/user-attachments/assets/c761bbb1-22c0-43df-ac2b-c58c1c538958 (shadcn/ui)

## Motivation

Enhancing the user experience on mobile devices. This feature would primarily benefit mobile users and developers building mobile-first applications, ensuring smoother interactions when dealing with modals and input fields.

Linked issue comments: user: login: mj12albert
created_at: 2025-02-18T07:44:15Z
body: Hey @borabaloglu in our docs demo, the Popup just has `position: fixed`, were you thinking of something like Positioner? e.g. https://base-ui.com/react/components/menu#positioner
author_association: MEMBER
user: login: atomiks
created_at: 2025-02-18T08:27:05Z
body: I think both of these are caused by the iOS scroll locking solution from `@react-aria/overlays` - when it's disabled there's no jump effect and the document slides so the input is visible. 

[`react-remove-scroll`](https://www.npmjs.com/package/react-remove-scroll) is the one used by Radix, maybe that can be used by Base UI for iOS instead which would fix the issues
author_association: CONTRIBUTOR
user: login: borabaloglu
created_at: 2025-02-18T11:25:42Z
body: > Hey [@borabaloglu](https://github.com/borabaloglu) in our docs demo, the Popup just has `position: fixed`, were you thinking of something like Positioner? e.g. https://base-ui.com/react/components/menu#positioner

Hey @mj12albert, All three examples I attached use `position: fixed`. I don’t think the Positioner is necessary in this case. @atomiks mentioned the issue, and it’s likely more related to scroll lock rather than positioning.

I’ve encountered issues with [`react-remove-scroll`](https://www.npmjs.com/package/react-remove-scroll) before. Specifically, the one discussed in [this issue](https://github.com/radix-ui/primitives/issues/1554).
author_association: NONE
user: login: atomiks
created_at: 2025-02-18T20:20:13Z
body: @borabaloglu pinch zooming doesn't work on iOS currently either with our solution, but it does work on every other platform
author_association: CONTRIBUTOR
user: login: atomiks
created_at: 2025-02-19T00:14:28Z
body: Looks like it has other issues on iOS:

- https://github.com/theKashey/react-remove-scroll/issues/101
- https://github.com/theKashey/react-remove-scroll/issues/130

Further, `RemoveScroll` from `react-remove-scroll` is a component and there's no clear way to avoid re-parenting when `keepMounted=true` or `open=false` and unmounting with an animation(?) — ideally it would be a hook or at least have a `disabled` prop on the component. We use a hook instead of a component, and RA's overlays package works well with that. 

Maybe an issue can be filed on the React Aria repo for this, instead? Worst case, we just don't lock scroll on iOS at all because there are _always_ so many issues with it 😖 

(Tried to create a [repro with RA alone](https://codesandbox.io/p/sandbox/zfsfkz) but it freezes as a standalone page on CodeSandbox)
author_association: CONTRIBUTOR
user: login: atomiks
created_at: 2025-04-17T01:19:20Z
body: Found a solution that appears to work: #1735
author_association: CONTRIBUTOR

Diff:
diff --git a/docs/src/app/(private)/experiments/mobile-scroll-lock.module.css b/docs/src/app/(private)/experiments/mobile-scroll-lock.module.css
new file mode 100644
index 00000000000..7d75dec8d5c
--- /dev/null
+++ b/docs/src/app/(private)/experiments/mobile-scroll-lock.module.css
@@ -0,0 +1,100 @@
+.Button {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 2.5rem;
+  padding: 0 0.875rem;
+  margin: 0;
+  outline: 0;
+  border: 1px solid var(--color-gray-200);
+  border-radius: 0.375rem;
+  background-color: var(--color-gray-50);
+  font-family: inherit;
+  font-size: 1rem;
+  font-weight: 500;
+  line-height: 1.5rem;
+  color: var(--color-gray-900);
+  user-select: none;
+
+  @media (hover: hover) {
+    &:hover {
+      background-color: var(--color-gray-100);
+    }
+  }
+
+  &:active {
+    background-color: var(--color-gray-100);
+  }
+
+  &:focus-visible {
+    outline: 2px solid var(--color-blue);
+    outline-offset: -1px;
+  }
+}
+
+.Backdrop {
+  position: fixed;
+  inset: 0;
+  background-color: black;
+  opacity: 0.2;
+  transition: opacity 150ms cubic-bezier(0.45, 1.005, 0, 1.005);
+
+  @media (prefers-color-scheme: dark) {
+    opacity: 0.7;
+  }
+
+  &[data-starting-style],
+  &[data-ending-style] {
+    opacity: 0;
+  }
+}
+
+.Popup {
+  box-sizing: border-box;
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  width: 24rem;
+  max-width: calc(100vw - 3rem);
+  margin-top: -2rem;
+  padding: 1.5rem;
+  border-radius: 0.5rem;
+  outline: 1px solid var(--color-gray-200);
+  background-color: var(--color-gray-50);
+  color: var(--color-gray-900);
+  transition: all 150ms;
+
+  @media (prefers-color-scheme: dark) {
+    outline: 1px solid var(--color-gray-300);
+  }
+
+  &[data-starting-style],
+  &[data-ending-style] {
+    opacity: 0;
+    transform: translate(-50%, -50%) scale(0.9);
+  }
+}
+
+.Title {
+  margin-top: -0.375rem;
+  margin-bottom: 0.25rem;
+  font-size: 1.125rem;
+  line-height: 1.75rem;
+  letter-spacing: -0.0025em;
+  font-weight: 500;
+}
+
+.Description {
+  margin: 0 0 1.5rem;
+  font-size: 1rem;
+  line-height: 1.5rem;
+  color: var(--color-gray-600);
+}
+
+.Actions {
+  display: flex;
+  justify-content: end;
+  gap: 1rem;
+}
diff --git a/docs/src/app/(private)/experiments/mobile-scroll-lock.tsx b/docs/src/app/(private)/experiments/mobile-scroll-lock.tsx
new file mode 100644
index 00000000000..95df8aa030e
--- /dev/null
+++ b/docs/src/app/(private)/experiments/mobile-scroll-lock.tsx
@@ -0,0 +1,44 @@
+import * as React from 'react';
+import { Dialog } from '@base-ui-components/react/dialog';
+import styles from './mobile-scroll-lock.module.css';
+
+export default function ExampleDialog() {
+  return (
+    <div style={{ height: 2000 }}>
+      <Dialog.Root>
+        <Dialog.Trigger className={styles.Button} style={{ marginTop: 500 }}>
+          View notifications
+        </Dialog.Trigger>
+        <Dialog.Portal>
+          <Dialog.Backdrop className={styles.Backdrop} />
+          <Dialog.Popup className={styles.Popup}>
+            <textarea
+              placeholder="What's on your mind?"
+              style={{ width: '100%', height: 300 }}
+            />
+            <div className={styles.Actions}>
+              <Dialog.Close className={styles.Button}>Close</Dialog.Close>
+            </div>
+            <Dialog.Root>
+              <Dialog.Trigger className={styles.Button}>
+                View notifications
+              </Dialog.Trigger>
+              <Dialog.Portal>
+                <Dialog.Backdrop className={styles.Backdrop} />
+                <Dialog.Popup className={styles.Popup}>
+                  <textarea
+                    placeholder="What's on your mind?"
+                    style={{ width: '100%', height: 300 }}
+                  />
+                  <div className={styles.Actions}>
+                    <Dialog.Close className={styles.Button}>Close</Dialog.Close>
+                  </div>
+                </Dialog.Popup>
+              </Dialog.Portal>
+            </Dialog.Root>
+          </Dialog.Popup>
+        </Dialog.Portal>
+      </Dialog.Root>
+    </div>
+  );
+}
diff --git a/packages/react/src/dialog/root/useDialogRoot.ts b/packages/react/src/dialog/root/useDialogRoot.ts
index 5379bd872c7..2e82008e6a9 100644
--- a/packages/react/src/dialog/root/useDialogRoot.ts
+++ b/packages/react/src/dialog/root/useDialogRoot.ts
@@ -23,6 +23,7 @@ import {
   type OpenChangeReason,
   translateOpenChangeReason,
 } from '../../utils/translateOpenChangeReason';
+import { useIOSKeyboardSlideFix } from '../../utils/useIOSKeyboardSlideFix';
 
 export function useDialogRoot(params: useDialogRoot.Parameters): useDialogRoot.ReturnValue {
   const {
@@ -53,6 +54,7 @@ export function useDialogRoot(params: useDialogRoot.Parameters): useDialogRoot.R
   );
   const [triggerElement, setTriggerElement] = React.useState<Element | null>(null);
   const [popupElement, setPopupElement] = React.useState<HTMLElement | null>(null);
+  const [allowIOSLock, setAllowIOSLock] = React.useState(true);
 
   const { mounted, setMounted, transitionStatus } = useTransitionStatus(open);
 
@@ -81,13 +83,6 @@ export function useDialogRoot(params: useDialogRoot.Parameters): useDialogRoot.R
 
   React.useImperativeHandle(params.actionsRef, () => ({ unmount: handleUnmount }), [handleUnmount]);
 
-  useScrollLock({
-    enabled: open && modal === true,
-    mounted,
-    open,
-    referenceElement: popupElement,
-  });
-
   const handleFloatingUIOpenChange = (
     nextOpen: boolean,
     event: Event | undefined,
@@ -130,7 +125,28 @@ export function useDialogRoot(params: useDialogRoot.Parameters): useDialogRoot.R
     escapeKey: isTopmost,
   });
 
-  const { getReferenceProps, getFloatingProps } = useInteractions([role, click, dismiss]);
+  const enableScrollLock = open && modal === true;
+  const enableScrollLockIOS = enableScrollLock && allowIOSLock;
+
+  const iOSKeyboardSlideFix = useIOSKeyboardSlideFix({
+    enabled: enableScrollLock,
+    setLock: setAllowIOSLock,
+    popupRef,
+  });
+
+  useScrollLock({
+    enabled: enableScrollLockIOS,
+    mounted,
+    open,
+    referenceElement: popupElement,
+  });
+
+  const { getReferenceProps, getFloatingProps } = useInteractions([
+    role,
+    click,
+    dismiss,
+    iOSKeyboardSlideFix,
+  ]);
 
   React.useEffect(() => {
     if (onNestedDialogOpen && open) {
diff --git a/packages/react/src/popover/root/usePopoverRoot.ts b/packages/react/src/popover/root/usePopoverRoot.ts
index f8c6c5ab1fa..a3502436a04 100644
--- a/packages/react/src/popover/root/usePopoverRoot.ts
+++ b/packages/react/src/popover/root/usePopoverRoot.ts
@@ -164,13 +164,8 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
       close: closeDelayWithDefault,
     },
   });
-
-  const click = useClick(context, {
-    stickIfOpen,
-  });
-
+  const click = useClick(context, { stickIfOpen });
   const dismiss = useDismiss(context);
-
   const role = useRole(context);
 
   const { getReferenceProps, getFloatingProps: getPopupProps } = useInteractions([
diff --git a/packages/react/src/utils/useIOSKeyboardSlideFix.ts b/packages/react/src/utils/useIOSKeyboardSlideFix.ts
new file mode 100644
index 00000000000..8142ecbdbfa
--- /dev/null
+++ b/packages/react/src/utils/useIOSKeyboardSlideFix.ts
@@ -0,0 +1,79 @@
+import * as React from 'react';
+import type { ElementProps } from '@floating-ui/react';
+import { getTarget, isTypeableElement } from '@floating-ui/react/utils';
+import { useEnhancedEffect } from './useEnhancedEffect';
+import { isIOS } from './detectBrowser';
+import { ownerDocument } from './owner';
+import { useEventCallback } from './useEventCallback';
+import { getPreventScrollCount } from './useScrollLock';
+
+/**
+ * Temporarily allows disabling the scroll lock when a typeable element (e.g. input or textarea)
+ * is focused. This allows the document to slide up when the keyboard is shown on iOS.
+ * Scroll needs to be manually restored since the sliding causes the page to scroll in the
+ * background.
+ * https://github.com/mui/base-ui/issues/1455
+ */
+export function useIOSKeyboardSlideFix(params: {
+  enabled: boolean;
+  popupRef: React.RefObject<HTMLElement | null>;
+  setLock: (lock: boolean) => void;
+}) {
+  const { enabled, setLock: setLockParam, popupRef } = params;
+
+  const setLock = useEventCallback(setLockParam);
+
+  const hasBeenEnabledRef = React.useRef(enabled);
+
+  useEnhancedEffect(() => {
+    if (!isIOS()) {
+      return undefined;
+    }
+
+    if (enabled) {
+      hasBeenEnabledRef.current = true;
+    }
+
+    if (!hasBeenEnabledRef.current) {
+      return undefined;
+    }
+
+    const doc = ownerDocument(popupRef.current);
+    const html = doc.documentElement;
+    if (enabled) {
+      const scrollX = html.scrollLeft;
+      const scrollY = html.scrollTop;
+      return () => {
+        // Doesn't work on Chrome iOS; Safari and Edge work correctly
+        if (getPreventScrollCount() === 0) {
+          html.scrollLeft = scrollX;
+          html.scrollTop = scrollY;
+        }
+      };
+    }
+
+    return undefined;
+  }, [enabled, popupRef]);
+
+  const handlePress = useEventCallback((event: React.FocusEvent | React.MouseEvent) => {
+    if (!isIOS()) {
+      return;
+    }
+
+    const target = getTarget(event.nativeEvent) as Element | null;
+    if (isTypeableElement(target)) {
+      setLock(false);
+      setTimeout(() => setLock(true));
+    }
+  });
+
+  return React.useMemo<ElementProps>(
+    () => ({
+      floating: {
+        onFocus: handlePress,
+        onClick: handlePress,
+      },
+    }),
+    [handlePress],
+  );
+}
diff --git a/packages/react/src/utils/useScrollLock.ts b/packages/react/src/utils/useScrollLock.ts
index 90f220ba2ea..2024ea1428f 100644
--- a/packages/react/src/utils/useScrollLock.ts
+++ b/packages/react/src/utils/useScrollLock.ts
@@ -10,6 +10,10 @@ let originalHtmlScrollBehavior = '';
 let preventScrollCount = 0;
 let restore: () => void = () => {};
 
+export function getPreventScrollCount() {
+  return preventScrollCount;
+}
+
 function supportsDvh() {
   return (
     typeof CSS !== 'undefined' &&


Reviews:
user: login: mj12albert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T06:27:23Z
user: login: atomiks
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T06:31:13Z
user: login: michaldudak
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T07:56:14Z
user: login: atomiks
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:14:36Z
user: login: michaldudak
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T08:34:07Z
user: login: atomiks
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:34:55Z
user: login: michaldudak
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T08:50:40Z
user: login: atomiks
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:53:46Z
user: login: michaldudak
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-17T09:44:47Z

Review comments:
diff_hunk: @@ -164,20 +166,32 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
       close: closeDelayWithDefault,
     },
   });
+  const click = useClick(context, { stickIfOpen });
+  const dismiss = useDismiss(context);
+  const role = useRole(context);
 
-  const click = useClick(context, {
-    stickIfOpen,
-  });
+  const enableScrollLock = open && modal === true;
+  const enableScrollLockIOS = enableScrollLock && allowIOSLock;
 
-  const dismiss = useDismiss(context);
+  const iOSDocumentSlide = useIOSKeyboardSlideFix({
path: packages/react/src/popover/root/usePopoverRoot.ts
user: login: mj12albert
body: Isn't this only for Dialog for now?
created_at: 2025-04-17T06:27:22Z
author_association: MEMBER
diff_hunk: @@ -164,20 +166,32 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
       close: closeDelayWithDefault,
     },
   });
+  const click = useClick(context, { stickIfOpen });
+  const dismiss = useDismiss(context);
+  const role = useRole(context);
 
-  const click = useClick(context, {
-    stickIfOpen,
-  });
+  const enableScrollLock = open && modal === true;
+  const enableScrollLockIOS = enableScrollLock && allowIOSLock;
 
-  const dismiss = useDismiss(context);
+  const iOSDocumentSlide = useIOSKeyboardSlideFix({
path: packages/react/src/popover/root/usePopoverRoot.ts
user: login: atomiks
body: Yeah, committed the work I was trying mistakenly - removed
created_at: 2025-04-17T06:31:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+import * as React from 'react';
+import type { ElementProps } from '@floating-ui/react';
+import { getTarget, isTypeableElement } from '@floating-ui/react/utils';
+import { useEnhancedEffect } from './useEnhancedEffect';
+import { isIOS } from './detectBrowser';
+import { ownerDocument } from './owner';
+import { useEventCallback } from './useEventCallback';
+import { getPreventScrollCount } from './useScrollLock';
+
+/**
+ * Temporarily allows disabling the scroll lock when a typeable element (e.g. input or textarea)
+ * is focused. This allows the document to slide up when the keyboard is shown on iOS.
+ * Scroll needs to be manually restored since the sliding causes the page to scroll in the
+ * background.
+ * https://github.com/mui/base-ui/issues/1455
+ */
+export function useIOSKeyboardSlideFix(params: {
+  enabled: boolean;
+  popupRef: React.RefObject<HTMLElement | null>;
+  setLock: (lock: boolean) => void;
+}) {
+  const { enabled, setLock: setLockParam, popupRef } = params;
+
+  const setLock = useEventCallback(setLockParam);
+
+  const scrollRef = React.useRef({ x: 0, y: 0 });
+  const hasBeenEnabledRef = React.useRef(enabled);
+
+  useEnhancedEffect(() => {
+    if (!isIOS()) {
+      return undefined;
+    }
+
+    if (enabled) {
+      hasBeenEnabledRef.current = true;
+    }
+
+    if (!hasBeenEnabledRef.current) {
+      return undefined;
+    }
+
+    const doc = ownerDocument(popupRef.current);
+    const html = doc.documentElement;
+    if (enabled) {
+      scrollRef.current = { x: html.scrollLeft, y: html.scrollTop };
+      return () => {
+        if (getPreventScrollCount() === 0) {
+          html.scrollTop = scrollRef.current.y;
+          html.scrollLeft = scrollRef.current.x;
+        }
+      };
+    }
+
+    return undefined;
+  }, [enabled, popupRef]);
+
+  const handlePress = useEventCallback((event: React.FocusEvent | React.MouseEvent) => {
+    if (!isIOS()) {
+      return;
+    }
+
+    const target = getTarget(event.nativeEvent) as Element | null;
+    if (isTypeableElement(target)) {
+      setLock(false);
+      setTimeout(() => setLock(true));
+    }
+  });
+
+  const floating: ElementProps['floating'] = React.useMemo(
+    () => ({
+      onFocus: handlePress,
+      onClick: handlePress,
+    }),
+    [handlePress],
+  );
+
+  return React.useMemo(() => ({ floating }), [floating]);
path: packages/react/src/utils/useIOSKeyboardSlideFix.ts
user: login: michaldudak
body: Let's merge these two `useMemo`s
created_at: 2025-04-17T07:56:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,78 @@
+import * as React from 'react';
+import type { ElementProps } from '@floating-ui/react';
+import { getTarget, isTypeableElement } from '@floating-ui/react/utils';
+import { useEnhancedEffect } from './useEnhancedEffect';
+import { isIOS } from './detectBrowser';
+import { ownerDocument } from './owner';
+import { useEventCallback } from './useEventCallback';
+import { getPreventScrollCount } from './useScrollLock';
+
+/**
+ * Temporarily allows disabling the scroll lock when a typeable element (e.g. input or textarea)
+ * is focused. This allows the document to slide up when the keyboard is shown on iOS.
+ * Scroll needs to be manually restored since the sliding causes the page to scroll in the
+ * background.
+ * https://github.com/mui/base-ui/issues/1455
+ */
+export function useIOSKeyboardSlideFix(params: {
+  enabled: boolean;
+  popupRef: React.RefObject<HTMLElement | null>;
+  setLock: (lock: boolean) => void;
+}) {
+  const { enabled, setLock: setLockParam, popupRef } = params;
+
+  const setLock = useEventCallback(setLockParam);
+
+  const scrollRef = React.useRef({ x: 0, y: 0 });
+  const hasBeenEnabledRef = React.useRef(enabled);
+
+  useEnhancedEffect(() => {
+    if (!isIOS()) {
+      return undefined;
+    }
+
+    if (enabled) {
+      hasBeenEnabledRef.current = true;
+    }
+
+    if (!hasBeenEnabledRef.current) {
+      return undefined;
+    }
+
+    const doc = ownerDocument(popupRef.current);
+    const html = doc.documentElement;
+    if (enabled) {
+      scrollRef.current = { x: html.scrollLeft, y: html.scrollTop };
+      return () => {
+        if (getPreventScrollCount() === 0) {
+          html.scrollTop = scrollRef.current.y;
+          html.scrollLeft = scrollRef.current.x;
+        }
+      };
+    }
+
+    return undefined;
+  }, [enabled, popupRef]);
+
+  const handlePress = useEventCallback((event: React.FocusEvent | React.MouseEvent) => {
+    if (!isIOS()) {
+      return;
+    }
+
+    const target = getTarget(event.nativeEvent) as Element | null;
+    if (isTypeableElement(target)) {
+      setLock(false);
+      setTimeout(() => setLock(true));
+    }
+  });
+
+  const floating: ElementProps['floating'] = React.useMemo(
+    () => ({
+      onFocus: handlePress,
+      onClick: handlePress,
+    }),
+    [handlePress],
+  );
+
+  return React.useMemo(() => ({ floating }), [floating]);
path: packages/react/src/utils/useIOSKeyboardSlideFix.ts
user: login: atomiks
body: Each one needs to be memo'd separately
created_at: 2025-04-17T08:14:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+import * as React from 'react';
+import type { ElementProps } from '@floating-ui/react';
+import { getTarget, isTypeableElement } from '@floating-ui/react/utils';
+import { useEnhancedEffect } from './useEnhancedEffect';
+import { isIOS } from './detectBrowser';
+import { ownerDocument } from './owner';
+import { useEventCallback } from './useEventCallback';
+import { getPreventScrollCount } from './useScrollLock';
+
+/**
+ * Temporarily allows disabling the scroll lock when a typeable element (e.g. input or textarea)
+ * is focused. This allows the document to slide up when the keyboard is shown on iOS.
+ * Scroll needs to be manually restored since the sliding causes the page to scroll in the
+ * background.
+ * https://github.com/mui/base-ui/issues/1455
+ */
+export function useIOSKeyboardSlideFix(params: {
+  enabled: boolean;
+  popupRef: React.RefObject<HTMLElement | null>;
+  setLock: (lock: boolean) => void;
+}) {
+  const { enabled, setLock: setLockParam, popupRef } = params;
+
+  const setLock = useEventCallback(setLockParam);
+
+  const scrollRef = React.useRef({ x: 0, y: 0 });
+  const hasBeenEnabledRef = React.useRef(enabled);
+
+  useEnhancedEffect(() => {
+    if (!isIOS()) {
+      return undefined;
+    }
+
+    if (enabled) {
+      hasBeenEnabledRef.current = true;
+    }
+
+    if (!hasBeenEnabledRef.current) {
+      return undefined;
+    }
+
+    const doc = ownerDocument(popupRef.current);
+    const html = doc.documentElement;
+    if (enabled) {
+      scrollRef.current = { x: html.scrollLeft, y: html.scrollTop };
+      return () => {
+        if (getPreventScrollCount() === 0) {
+          html.scrollTop = scrollRef.current.y;
+          html.scrollLeft = scrollRef.current.x;
+        }
+      };
+    }
+
+    return undefined;
+  }, [enabled, popupRef]);
+
+  const handlePress = useEventCallback((event: React.FocusEvent | React.MouseEvent) => {
+    if (!isIOS()) {
+      return;
+    }
+
+    const target = getTarget(event.nativeEvent) as Element | null;
+    if (isTypeableElement(target)) {
+      setLock(false);
+      setTimeout(() => setLock(true));
+    }
+  });
+
+  const floating: ElementProps['floating'] = React.useMemo(
+    () => ({
+      onFocus: handlePress,
+      onClick: handlePress,
+    }),
+    [handlePress],
+  );
+
+  return React.useMemo(() => ({ floating }), [floating]);
path: packages/react/src/utils/useIOSKeyboardSlideFix.ts
user: login: michaldudak
body: Why can't we make it
```
return React.useMemo(
  () => ({
    floating: {
      onFocus: handlePress,
      onClick: handlePress,
    },
  }),
  [handlePress],
);
```
created_at: 2025-04-17T08:34:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,78 @@
+import * as React from 'react';
+import type { ElementProps } from '@floating-ui/react';
+import { getTarget, isTypeableElement } from '@floating-ui/react/utils';
+import { useEnhancedEffect } from './useEnhancedEffect';
+import { isIOS } from './detectBrowser';
+import { ownerDocument } from './owner';
+import { useEventCallback } from './useEventCallback';
+import { getPreventScrollCount } from './useScrollLock';
+
+/**
+ * Temporarily allows disabling the scroll lock when a typeable element (e.g. input or textarea)
+ * is focused. This allows the document to slide up when the keyboard is shown on iOS.
+ * Scroll needs to be manually restored since the sliding causes the page to scroll in the
+ * background.
+ * https://github.com/mui/base-ui/issues/1455
+ */
+export function useIOSKeyboardSlideFix(params: {
+  enabled: boolean;
+  popupRef: React.RefObject<HTMLElement | null>;
+  setLock: (lock: boolean) => void;
+}) {
+  const { enabled, setLock: setLockParam, popupRef } = params;
+
+  const setLock = useEventCallback(setLockParam);
+
+  const scrollRef = React.useRef({ x: 0, y: 0 });
+  const hasBeenEnabledRef = React.useRef(enabled);
+
+  useEnhancedEffect(() => {
+    if (!isIOS()) {
+      return undefined;
+    }
+
+    if (enabled) {
+      hasBeenEnabledRef.current = true;
+    }
+
+    if (!hasBeenEnabledRef.current) {
+      return undefined;
+    }
+
+    const doc = ownerDocument(popupRef.current);
+    const html = doc.documentElement;
+    if (enabled) {
+      scrollRef.current = { x: html.scrollLeft, y: html.scrollTop };
+      return () => {
+        if (getPreventScrollCount() === 0) {
+          html.scrollTop = scrollRef.current.y;
+          html.scrollLeft = scrollRef.current.x;
+        }
+      };
+    }
+
+    return undefined;
+  }, [enabled, popupRef]);
+
+  const handlePress = useEventCallback((event: React.FocusEvent | React.MouseEvent) => {
+    if (!isIOS()) {
+      return;
+    }
+
+    const target = getTarget(event.nativeEvent) as Element | null;
+    if (isTypeableElement(target)) {
+      setLock(false);
+      setTimeout(() => setLock(true));
+    }
+  });
+
+  const floating: ElementProps['floating'] = React.useMemo(
+    () => ({
+      onFocus: handlePress,
+      onClick: handlePress,
+    }),
+    [handlePress],
+  );
+
+  return React.useMemo(() => ({ floating }), [floating]);
path: packages/react/src/utils/useIOSKeyboardSlideFix.ts
user: login: atomiks
body: `useInteractions` uses `.floating` property as a dependency in `useCallback`
created_at: 2025-04-17T08:34:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+import * as React from 'react';
+import type { ElementProps } from '@floating-ui/react';
+import { getTarget, isTypeableElement } from '@floating-ui/react/utils';
+import { useEnhancedEffect } from './useEnhancedEffect';
+import { isIOS } from './detectBrowser';
+import { ownerDocument } from './owner';
+import { useEventCallback } from './useEventCallback';
+import { getPreventScrollCount } from './useScrollLock';
+
+/**
+ * Temporarily allows disabling the scroll lock when a typeable element (e.g. input or textarea)
+ * is focused. This allows the document to slide up when the keyboard is shown on iOS.
+ * Scroll needs to be manually restored since the sliding causes the page to scroll in the
+ * background.
+ * https://github.com/mui/base-ui/issues/1455
+ */
+export function useIOSKeyboardSlideFix(params: {
+  enabled: boolean;
+  popupRef: React.RefObject<HTMLElement | null>;
+  setLock: (lock: boolean) => void;
+}) {
+  const { enabled, setLock: setLockParam, popupRef } = params;
+
+  const setLock = useEventCallback(setLockParam);
+
+  const scrollRef = React.useRef({ x: 0, y: 0 });
+  const hasBeenEnabledRef = React.useRef(enabled);
+
+  useEnhancedEffect(() => {
+    if (!isIOS()) {
+      return undefined;
+    }
+
+    if (enabled) {
+      hasBeenEnabledRef.current = true;
+    }
+
+    if (!hasBeenEnabledRef.current) {
+      return undefined;
+    }
+
+    const doc = ownerDocument(popupRef.current);
+    const html = doc.documentElement;
+    if (enabled) {
+      scrollRef.current = { x: html.scrollLeft, y: html.scrollTop };
+      return () => {
+        if (getPreventScrollCount() === 0) {
+          html.scrollTop = scrollRef.current.y;
+          html.scrollLeft = scrollRef.current.x;
+        }
+      };
+    }
+
+    return undefined;
+  }, [enabled, popupRef]);
+
+  const handlePress = useEventCallback((event: React.FocusEvent | React.MouseEvent) => {
+    if (!isIOS()) {
+      return;
+    }
+
+    const target = getTarget(event.nativeEvent) as Element | null;
+    if (isTypeableElement(target)) {
+      setLock(false);
+      setTimeout(() => setLock(true));
+    }
+  });
+
+  const floating: ElementProps['floating'] = React.useMemo(
+    () => ({
+      onFocus: handlePress,
+      onClick: handlePress,
+    }),
+    [handlePress],
+  );
+
+  return React.useMemo(() => ({ floating }), [floating]);
path: packages/react/src/utils/useIOSKeyboardSlideFix.ts
user: login: michaldudak
body: Does it matter? The value returned from this hook is the same regardless of how many memos are used, no? It's just a matter of optimization.
The memoized value won't ever change anyway as `handlePress` is stable.
created_at: 2025-04-17T08:50:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,78 @@
+import * as React from 'react';
+import type { ElementProps } from '@floating-ui/react';
+import { getTarget, isTypeableElement } from '@floating-ui/react/utils';
+import { useEnhancedEffect } from './useEnhancedEffect';
+import { isIOS } from './detectBrowser';
+import { ownerDocument } from './owner';
+import { useEventCallback } from './useEventCallback';
+import { getPreventScrollCount } from './useScrollLock';
+
+/**
+ * Temporarily allows disabling the scroll lock when a typeable element (e.g. input or textarea)
+ * is focused. This allows the document to slide up when the keyboard is shown on iOS.
+ * Scroll needs to be manually restored since the sliding causes the page to scroll in the
+ * background.
+ * https://github.com/mui/base-ui/issues/1455
+ */
+export function useIOSKeyboardSlideFix(params: {
+  enabled: boolean;
+  popupRef: React.RefObject<HTMLElement | null>;
+  setLock: (lock: boolean) => void;
+}) {
+  const { enabled, setLock: setLockParam, popupRef } = params;
+
+  const setLock = useEventCallback(setLockParam);
+
+  const scrollRef = React.useRef({ x: 0, y: 0 });
+  const hasBeenEnabledRef = React.useRef(enabled);
+
+  useEnhancedEffect(() => {
+    if (!isIOS()) {
+      return undefined;
+    }
+
+    if (enabled) {
+      hasBeenEnabledRef.current = true;
+    }
+
+    if (!hasBeenEnabledRef.current) {
+      return undefined;
+    }
+
+    const doc = ownerDocument(popupRef.current);
+    const html = doc.documentElement;
+    if (enabled) {
+      scrollRef.current = { x: html.scrollLeft, y: html.scrollTop };
+      return () => {
+        if (getPreventScrollCount() === 0) {
+          html.scrollTop = scrollRef.current.y;
+          html.scrollLeft = scrollRef.current.x;
+        }
+      };
+    }
+
+    return undefined;
+  }, [enabled, popupRef]);
+
+  const handlePress = useEventCallback((event: React.FocusEvent | React.MouseEvent) => {
+    if (!isIOS()) {
+      return;
+    }
+
+    const target = getTarget(event.nativeEvent) as Element | null;
+    if (isTypeableElement(target)) {
+      setLock(false);
+      setTimeout(() => setLock(true));
+    }
+  });
+
+  const floating: ElementProps['floating'] = React.useMemo(
+    () => ({
+      onFocus: handlePress,
+      onClick: handlePress,
+    }),
+    [handlePress],
+  );
+
+  return React.useMemo(() => ({ floating }), [floating]);
path: packages/react/src/utils/useIOSKeyboardSlideFix.ts
user: login: atomiks
body: Yeah you're right - the nested object should also be stable in this case since the top level object itself is stable between renders
created_at: 2025-04-17T08:53:46Z
author_association: CONTRIBUTOR

PR comments:
user: login: netlify[bot]
created_at: 2025-04-17T01:12:47Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *base-ui* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 3781ec071ef54310db51f6ba8930b3a595518c4a |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/base-ui/deploys/6800cc5a2a399f000862ffb9 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-1735--base-ui.netlify.app](https://deploy-preview-1735--base-ui.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTE3MzUtLWJhc2UtdWkubmV0bGlmeS5hcHAifQ.Go7dM_ubtKWZqOGX1z7xrUzch6atNGPOQ_G4S3BzB6I)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [base-ui Preview](https://deploy-preview-1735--base-ui.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/base-ui/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: michaldudak
created_at: 2025-04-17T06:54:07Z
body: It only works on the first interaction. After I dismiss the keyboard and focus the textarea again, the dialog doesn't shift.
author_association: MEMBER
user: login: atomiks
created_at: 2025-04-17T07:02:30Z
body: @michaldudak it works for me every time
author_association: CONTRIBUTOR
user: login: michaldudak
created_at: 2025-04-17T07:17:09Z
body: Here's how it looks on Edge on iPhone 16 Pro (iOS 18.3.2):

https://github.com/user-attachments/assets/b1784448-4c16-4522-829e-221bafb84b52

It's the same on Chrome, but it works well on Safari.


author_association: MEMBER
user: login: mj12albert
created_at: 2025-04-17T07:26:02Z
body: > It's the same on Chrome, but it works well on Safari.

I was about to record the same thing on Chrome + iOS, after the textarea is blurred and the keyboard goes away, "View notifications" is no longer visible just underneath the popup
author_association: MEMBER
user: login: atomiks
created_at: 2025-04-17T07:29:12Z
body: > I was about to record the same thing on Chrome + iOS, after the textarea is blurred and the keyboard goes away, "View notifications" is no longer visible just underneath the popup

That's this issue

>  Scroll needs to be manually restored since the sliding causes the page to scroll in the background.

I think the tradeoff is acceptable in this case; the scroll position is restored manually so it's in the same position as when it was opened
author_association: CONTRIBUTOR
user: login: atomiks
created_at: 2025-04-17T08:24:34Z
body: > I was about to record the same thing on Chrome + iOS, after the textarea is blurred and the keyboard goes away, "View notifications" is no longer visible just underneath the popup

This restoration issue actually happens with non-modal dialogs already since it slides the background a bit each time the textarea gets focused. 

In Chrome iOS it doesn't restore it properly in this PR, but Safari/Edge work correctly. We can either match non-modal dialogs (accept that the background slides each time) or restore it using the existing logic which is not foolproof
author_association: CONTRIBUTOR