From 9123f2df060e774674305e3442b687f175ce727a Mon Sep 17 00:00:00 2001 From: mrlexcoder Date: Tue, 24 Mar 2026 23:12:33 +0530 Subject: [PATCH 1/3] fix(s2/Toast): offset toast above on-screen keyboard on mobile Fixes #9681 When the virtual keyboard is open on mobile, position:fixed elements are placed relative to the layout viewport, not the visual viewport. This causes bottom-placed toasts to render underneath the keyboard. Add a useKeyboardOffset() hook that listens to visualViewport resize and scroll events and computes the keyboard height as: keyboardHeight = window.innerHeight - (vv.offsetTop + vv.height) When a non-zero offset is detected and placement is 'bottom', the ToastRegion's bottom position is overridden via an inline style to sit 16px above the top of the keyboard instead of 16px above the bottom of the layout viewport. The hook returns 0 when visualViewport is unavailable (SSR / older browsers), so desktop and server rendering are unaffected. Signed-off-by: mrlexcoder --- packages/@react-spectrum/s2/src/Toast.tsx | 44 ++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/Toast.tsx b/packages/@react-spectrum/s2/src/Toast.tsx index 0507726bcb4..70e1484f606 100644 --- a/packages/@react-spectrum/s2/src/Toast.tsx +++ b/packages/@react-spectrum/s2/src/Toast.tsx @@ -17,7 +17,7 @@ import {CenterBaseline} from './CenterBaseline'; import CheckmarkIcon from '../s2wf-icons/S2_Icon_CheckmarkCircle_20_N.svg'; import Chevron from '../s2wf-icons/S2_Icon_ChevronDown_20_N.svg'; import {CloseButton} from './CloseButton'; -import {createContext, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; +import {createContext, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {DOMProps} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/private/utils/filterDOMProps'; import {flushSync} from 'react-dom'; @@ -347,6 +347,43 @@ interface ToastContainerContextValue { const ToastContainerContext = createContext(null); +/** + * Returns the number of pixels the visual viewport is offset from the bottom + * of the layout viewport (i.e. how much the on-screen keyboard has pushed the + * visible area up). Returns 0 when no keyboard is present or the API is + * unavailable. + */ +function useKeyboardOffset(): number { + let [offset, setOffset] = useState(0); + + useEffect(() => { + let vv = typeof window !== 'undefined' ? window.visualViewport : null; + if (!vv) { + return; + } + + let update = () => { + // offsetTop is the distance from the top of the layout viewport to the + // top of the visual viewport. When the keyboard is open the visual + // viewport shrinks upward, so: + // keyboardHeight = layoutHeight - (vv.offsetTop + vv.height) + let keyboardHeight = window.innerHeight - (vv!.offsetTop + vv!.height); + setOffset(Math.max(0, Math.round(keyboardHeight))); + }; + + vv.addEventListener('resize', update); + vv.addEventListener('scroll', update); + update(); + + return () => { + vv!.removeEventListener('resize', update); + vv!.removeEventListener('scroll', update); + }; + }, []); + + return offset; +} + /** * A ToastContainer renders the queued toasts in an application. It should be placed * at the root of the app. @@ -361,6 +398,10 @@ export function ToastContainer(props: ToastContainerProps): ReactNode { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); let regionRef = useRef(null); + // Offset the toast region above the on-screen keyboard on mobile. + let keyboardOffset = useKeyboardOffset(); + let isBottom = placement === 'bottom'; + let state = useOverlayTriggerState({}); let {isOpen: isExpanded, close, toggle} = state; let ctx = useMemo(() => ({ @@ -415,6 +456,7 @@ export function ToastContainer(props: ToastContainerProps): ReactNode { {...props} ref={regionRef} queue={queue} + style={isBottom && keyboardOffset > 0 ? {bottom: keyboardOffset + 16} : undefined} className={renderProps => toastRegion({ ...renderProps, placement, From 19da78337cf3ffd99a42fb90e279499faea1a817 Mon Sep 17 00:00:00 2001 From: mrlexcoder Date: Wed, 25 Mar 2026 06:51:38 +0530 Subject: [PATCH 2/3] fix(s2/Toast): use CSS variable for keyboard offset per review feedback - Replace inline bottom style override with a --keyboardOffset CSS variable fed into the toastRegion style macro via calc() - bottom is now: calc(var(--keyboardOffset, 0px) + 16px) - Variable is only set when placement is 'bottom' - Avoids inline style property conflict with the style macro Signed-off-by: mrlexcoder --- packages/@react-spectrum/s2/src/Toast.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Toast.tsx b/packages/@react-spectrum/s2/src/Toast.tsx index 70e1484f606..dbbe68a93c6 100644 --- a/packages/@react-spectrum/s2/src/Toast.tsx +++ b/packages/@react-spectrum/s2/src/Toast.tsx @@ -17,7 +17,7 @@ import {CenterBaseline} from './CenterBaseline'; import CheckmarkIcon from '../s2wf-icons/S2_Icon_CheckmarkCircle_20_N.svg'; import Chevron from '../s2wf-icons/S2_Icon_ChevronDown_20_N.svg'; import {CloseButton} from './CloseButton'; -import {createContext, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {CSSProperties, createContext, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {DOMProps} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/private/utils/filterDOMProps'; import {flushSync} from 'react-dom'; @@ -179,7 +179,9 @@ const toastRegion = style({ bottom: { placement: { bottom: { - default: 16, + // Use a CSS variable so the keyboard offset can be injected at runtime + // without an inline style override. Falls back to 0 when not set. + default: 'calc(var(--keyboardOffset, 0px) + 16px)' as any, isExpanded: 0 } } @@ -456,7 +458,7 @@ export function ToastContainer(props: ToastContainerProps): ReactNode { {...props} ref={regionRef} queue={queue} - style={isBottom && keyboardOffset > 0 ? {bottom: keyboardOffset + 16} : undefined} + style={isBottom ? {'--keyboardOffset': `${keyboardOffset}px`} as CSSProperties : undefined} className={renderProps => toastRegion({ ...renderProps, placement, From 4cf9c4e583b6ac32002e17f570da744596d966ed Mon Sep 17 00:00:00 2001 From: mrlexcoder Date: Thu, 26 Mar 2026 22:49:36 +0530 Subject: [PATCH 3/3] fix(s2/Toast): use pure CSS dvh approach for keyboard offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the JS visualViewport hook with a pure CSS solution: bottom: calc(100vh - 100dvh + 16px) When the on-screen keyboard is open, dvh (dynamic viewport height) shrinks to the visible area while vh (layout viewport) stays fixed. The difference (100vh - 100dvh) equals the keyboard height, so the toast sits 16px above the keyboard automatically. On desktop where no keyboard is present, 100vh === 100dvh so the expression reduces to 16px — identical to the previous behaviour. No JS, no event listeners, no CSS variables needed. Signed-off-by: mrlexcoder --- packages/@react-spectrum/s2/src/Toast.tsx | 53 +++-------------------- 1 file changed, 7 insertions(+), 46 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Toast.tsx b/packages/@react-spectrum/s2/src/Toast.tsx index dbbe68a93c6..4d93d206987 100644 --- a/packages/@react-spectrum/s2/src/Toast.tsx +++ b/packages/@react-spectrum/s2/src/Toast.tsx @@ -17,7 +17,7 @@ import {CenterBaseline} from './CenterBaseline'; import CheckmarkIcon from '../s2wf-icons/S2_Icon_CheckmarkCircle_20_N.svg'; import Chevron from '../s2wf-icons/S2_Icon_ChevronDown_20_N.svg'; import {CloseButton} from './CloseButton'; -import {CSSProperties, createContext, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {createContext, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {DOMProps} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/private/utils/filterDOMProps'; import {flushSync} from 'react-dom'; @@ -179,9 +179,12 @@ const toastRegion = style({ bottom: { placement: { bottom: { - // Use a CSS variable so the keyboard offset can be injected at runtime - // without an inline style override. Falls back to 0 when not set. - default: 'calc(var(--keyboardOffset, 0px) + 16px)' as any, + // On mobile, when the on-screen keyboard is open the dynamic viewport + // (dvh) shrinks while the layout viewport (vh) stays the same. + // calc(100vh - 100dvh + 16px) equals the keyboard height + 16px, + // keeping the toast above the keyboard on any browser that supports dvh. + // On desktop (no keyboard) 100vh === 100dvh so this reduces to 16px. + default: '[calc(100vh - 100dvh + 16px)]', isExpanded: 0 } } @@ -349,43 +352,6 @@ interface ToastContainerContextValue { const ToastContainerContext = createContext(null); -/** - * Returns the number of pixels the visual viewport is offset from the bottom - * of the layout viewport (i.e. how much the on-screen keyboard has pushed the - * visible area up). Returns 0 when no keyboard is present or the API is - * unavailable. - */ -function useKeyboardOffset(): number { - let [offset, setOffset] = useState(0); - - useEffect(() => { - let vv = typeof window !== 'undefined' ? window.visualViewport : null; - if (!vv) { - return; - } - - let update = () => { - // offsetTop is the distance from the top of the layout viewport to the - // top of the visual viewport. When the keyboard is open the visual - // viewport shrinks upward, so: - // keyboardHeight = layoutHeight - (vv.offsetTop + vv.height) - let keyboardHeight = window.innerHeight - (vv!.offsetTop + vv!.height); - setOffset(Math.max(0, Math.round(keyboardHeight))); - }; - - vv.addEventListener('resize', update); - vv.addEventListener('scroll', update); - update(); - - return () => { - vv!.removeEventListener('resize', update); - vv!.removeEventListener('scroll', update); - }; - }, []); - - return offset; -} - /** * A ToastContainer renders the queued toasts in an application. It should be placed * at the root of the app. @@ -400,10 +366,6 @@ export function ToastContainer(props: ToastContainerProps): ReactNode { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); let regionRef = useRef(null); - // Offset the toast region above the on-screen keyboard on mobile. - let keyboardOffset = useKeyboardOffset(); - let isBottom = placement === 'bottom'; - let state = useOverlayTriggerState({}); let {isOpen: isExpanded, close, toggle} = state; let ctx = useMemo(() => ({ @@ -458,7 +420,6 @@ export function ToastContainer(props: ToastContainerProps): ReactNode { {...props} ref={regionRef} queue={queue} - style={isBottom ? {'--keyboardOffset': `${keyboardOffset}px`} as CSSProperties : undefined} className={renderProps => toastRegion({ ...renderProps, placement,