From 13eafc2a727efdfdb1a6aa4aaf5dbc72419a2764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Mon, 25 May 2026 18:09:07 +0200 Subject: [PATCH 1/7] fix: modal layout shift when auto-focusing input --- packages/react-aria-components/src/Modal.tsx | 59 +++++++++++++++---- .../src/overlays/usePreventScroll.ts | 40 ++++++++----- 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/packages/react-aria-components/src/Modal.tsx b/packages/react-aria-components/src/Modal.tsx index ecde2ec2385..2d918a5763a 100644 --- a/packages/react-aria-components/src/Modal.tsx +++ b/packages/react-aria-components/src/Modal.tsx @@ -11,7 +11,8 @@ */ import {AriaModalOverlayProps, useModalOverlay} from 'react-aria/useModalOverlay'; - +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; +import {willOpenKeyboard} from 'react-aria/private/utils/keyboard'; import { ClassNameOrFunction, ContextValue, @@ -34,11 +35,21 @@ import { useOverlayTriggerState } from 'react-stately/useOverlayTriggerState'; import {OverlayTriggerStateContext} from './Dialog'; -import React, {createContext, ForwardedRef, forwardRef, useContext, useMemo, useRef} from 'react'; +import React, { + createContext, + ForwardedRef, + forwardRef, + useContext, + useMemo, + useRef, + useState +} from 'react'; import {useEnterAnimation, useExitAnimation} from 'react-aria/private/utils/animation'; import {useIsSSR} from 'react-aria/SSRProvider'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useViewportSize} from 'react-aria/private/utils/useViewportSize'; +import {getActiveElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; export interface ModalOverlayProps extends @@ -300,11 +311,12 @@ interface ModalContentProps function ModalContent(props: ModalContentProps) { let {modalProps, modalRef, isExiting, isDismissable} = useContext(InternalModalContext)!; + let [isOpen, setOpen] = useState(false); let state = useContext(OverlayTriggerStateContext)!; let mergedRefs = useMemo(() => mergeRefs(props.modalRef, modalRef), [props.modalRef, modalRef]); let ref = useObjectRef(mergedRefs); - let entering = useEnterAnimation(ref); + let entering = useEnterAnimation(ref, isOpen); let renderProps = useRenderProps({ ...props, defaultClassName: 'react-aria-Modal', @@ -315,15 +327,40 @@ function ModalContent(props: ModalContentProps) { } }); + // Hide the modal initially, since an auto-focused input may cause a viewport resize in the next frame. + // If so, delay the reveal by another frame to avoid layout shift when the viewport settles. + useLayoutEffect(() => { + let frame: number, frame2: number; + + frame = requestAnimationFrame(() => { + let activeElement = getActiveElement(); + if (activeElement && willOpenKeyboard(activeElement)) { + frame2 = requestAnimationFrame(() => setOpen(true)); + } else { + setOpen(true); + } + }); + + return () => { + cancelAnimationFrame(frame); + cancelAnimationFrame(frame2); + }; + }, []); + + let {visuallyHiddenProps} = useVisuallyHidden(); + let contentStyle = isOpen ? {display: 'contents'} : visuallyHiddenProps.style; + return ( - - {isDismissable && } - {renderProps.children} + + + {isDismissable && } + {renderProps.children} + ); } diff --git a/packages/react-aria/src/overlays/usePreventScroll.ts b/packages/react-aria/src/overlays/usePreventScroll.ts index 6ed499570d6..1366f1c9c79 100644 --- a/packages/react-aria/src/overlays/usePreventScroll.ts +++ b/packages/react-aria/src/overlays/usePreventScroll.ts @@ -11,11 +11,10 @@ */ import {chain} from '../utils/chain'; - import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; import {getNonce} from '../utils/getNonce'; import {getScrollParent} from '../utils/getScrollParent'; -import {isIOS} from '../utils/platform'; +import {isIOS, isWebKit} from '../utils/platform'; import {isScrollable} from '../utils/isScrollable'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {willOpenKeyboard} from '../utils/keyboard'; @@ -36,6 +35,9 @@ let restore; * restores it on unmount. Also ensures that content does not * shift due to the scrollbars disappearing. */ +// TODO(Docs): Fixed outdated documentation of IOS Safari scroll prevention. +// TODO(Docs): Fixed crash when attempting to override focus in test scenarios. +// TODO(Docs): Fixed platform detection causing IOS Safari prevention to run in other engines. export function usePreventScroll(options: PreventScrollOptions = {}): void { let {isDisabled} = options; @@ -46,7 +48,7 @@ export function usePreventScroll(options: PreventScrollOptions = {}): void { preventScrollCount++; if (preventScrollCount === 1) { - if (isIOS()) { + if (isIOS() && isWebKit()) { restore = preventScrollMobileSafari(); } else { restore = preventScrollStandard(); @@ -197,18 +199,22 @@ function preventScrollMobileSafari() { // Override programmatic focus to scroll into view without scrolling the whole page. let focus = HTMLElement.prototype.focus; - HTMLElement.prototype.focus = function (opts) { - // Track whether the keyboard was already visible before. - let activeElement = getActiveElement(); - let wasKeyboardVisible = activeElement != null && willOpenKeyboard(activeElement); - - // Focus the element without scrolling the page. - focus.call(this, {...opts, preventScroll: true}); - - if (!opts || !opts.preventScroll) { - scrollIntoViewWhenReady(this, wasKeyboardVisible); + Reflect.defineProperty(HTMLElement.prototype, 'focus', { + configurable: true, + writable: true, + value: function (opts?: FocusOptions) { + // Track whether the keyboard was already visible before. + let activeElement = getActiveElement(); + let wasKeyboardVisible = activeElement != null && willOpenKeyboard(activeElement); + + // Focus the element without scrolling the page. + focus.call(this, {...opts, preventScroll: true}); + + if (!opts || !opts.preventScroll) { + scrollIntoViewWhenReady(this, wasKeyboardVisible); + } } - }; + }); let removeEvents = chain( addEvent(document, 'touchstart', onTouchStart, {passive: false, capture: true}), @@ -220,7 +226,11 @@ function preventScrollMobileSafari() { restoreOverflow(); removeEvents(); style.remove(); - HTMLElement.prototype.focus = focus; + Reflect.defineProperty(HTMLElement.prototype, 'focus', { + configurable: true, + writable: true, + value: focus + }); }; } From 64a2d762d0ba561ea601ae2089c10b89b2792c75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Mon, 25 May 2026 18:09:56 +0200 Subject: [PATCH 2/7] chore: remove comments --- packages/react-aria/src/overlays/usePreventScroll.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/react-aria/src/overlays/usePreventScroll.ts b/packages/react-aria/src/overlays/usePreventScroll.ts index 1366f1c9c79..69cc0c8ae52 100644 --- a/packages/react-aria/src/overlays/usePreventScroll.ts +++ b/packages/react-aria/src/overlays/usePreventScroll.ts @@ -35,9 +35,6 @@ let restore; * restores it on unmount. Also ensures that content does not * shift due to the scrollbars disappearing. */ -// TODO(Docs): Fixed outdated documentation of IOS Safari scroll prevention. -// TODO(Docs): Fixed crash when attempting to override focus in test scenarios. -// TODO(Docs): Fixed platform detection causing IOS Safari prevention to run in other engines. export function usePreventScroll(options: PreventScrollOptions = {}): void { let {isDisabled} = options; From 8bf7085901702c9dcd45e52a9d7d930c34a21d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Mon, 25 May 2026 18:29:33 +0200 Subject: [PATCH 3/7] chore: lint --- packages/react-aria-components/src/Modal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/src/Modal.tsx b/packages/react-aria-components/src/Modal.tsx index 2d918a5763a..811d4c36581 100644 --- a/packages/react-aria-components/src/Modal.tsx +++ b/packages/react-aria-components/src/Modal.tsx @@ -11,8 +11,6 @@ */ import {AriaModalOverlayProps, useModalOverlay} from 'react-aria/useModalOverlay'; -import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; -import {willOpenKeyboard} from 'react-aria/private/utils/keyboard'; import { ClassNameOrFunction, ContextValue, @@ -26,6 +24,7 @@ import { import {DismissButton, Overlay} from 'react-aria/Overlay'; import {DOMAttributes, forwardRefType, GlobalDOMAttributes, RefObject} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {getActiveElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {isScrollable} from 'react-aria/private/utils/isScrollable'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; @@ -46,10 +45,11 @@ import React, { } from 'react'; import {useEnterAnimation, useExitAnimation} from 'react-aria/private/utils/animation'; import {useIsSSR} from 'react-aria/SSRProvider'; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useViewportSize} from 'react-aria/private/utils/useViewportSize'; -import {getActiveElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; +import {willOpenKeyboard} from 'react-aria/private/utils/keyboard'; export interface ModalOverlayProps extends From 522b02e32e01bbe759ab3ede20d5ae841149c42c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Wed, 10 Jun 2026 02:15:02 +0200 Subject: [PATCH 4/7] feat: generic keyboard observation and transition hooks --- .../docs/pages/react-aria/getting-started.mdx | 4 + .../docs/pages/react-aria/home/ExampleApp.tsx | 3 +- packages/react-aria-components/docs/Modal.mdx | 4 + .../react-aria-components/docs/Popover.mdx | 8 + .../docs/examples/command-palette.mdx | 3 +- .../docs/examples/destructive-dialog.mdx | 3 +- .../react-aria-components/docs/styling.mdx | 12 ++ packages/react-aria-components/src/Modal.tsx | 84 ++++----- .../react-aria-components/src/Popover.tsx | 19 ++- .../exports/private/utils/runAfterKeyboard.ts | 1 + .../src/overlays/calculatePosition.ts | 5 +- packages/react-aria/src/utils/domHelpers.ts | 85 ++++++++-- packages/react-aria/src/utils/keyboard.tsx | 134 ++++++++++++--- packages/react-aria/src/utils/platform.ts | 6 +- .../react-aria/src/utils/runAfterKeyboard.ts | 159 ++++++++++++++++++ .../src/utils/runAfterTransition.ts | 143 +++++++++------- packages/react-aria/src/utils/useEvent.ts | 44 +++-- .../react-aria/src/utils/useViewportSize.ts | 33 ++-- starters/docs/src/Modal.css | 4 + starters/docs/src/Popover.css | 4 + starters/tailwind/src/Modal.tsx | 4 +- 21 files changed, 585 insertions(+), 177 deletions(-) create mode 100644 packages/react-aria/exports/private/utils/runAfterKeyboard.ts create mode 100644 packages/react-aria/src/utils/runAfterKeyboard.ts diff --git a/packages/dev/docs/pages/react-aria/getting-started.mdx b/packages/dev/docs/pages/react-aria/getting-started.mdx index e7a3eb88af8..f94e5fba948 100644 --- a/packages/dev/docs/pages/react-aria/getting-started.mdx +++ b/packages/dev/docs/pages/react-aria/getting-started.mdx @@ -193,6 +193,10 @@ This is a quick way to get started, but you can also create your own custom clas --origin: translateY(-8px); } + &:not([data-open]) { + opacity: 0; + } + &[data-entering] { animation: slide 200ms; } diff --git a/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx b/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx index c1fdf6b6a87..c3b9a2248a0 100644 --- a/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx +++ b/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx @@ -787,7 +787,7 @@ function PlantModal(props: ModalOverlayProps) { ${isEntering ? 'animate-in fade-in duration-200 ease-out' : ''} ${isExiting ? 'animate-out fade-out duration-200 ease-in' : ''} `}> - {({isEntering, isExiting}) => ( + {({isEntering, isExiting, isOpen}) => ( <> {/* Inner position: sticky div sized to the visual viewport height so the modal appears in view. @@ -799,6 +799,7 @@ function PlantModal(props: ModalOverlayProps) {