Skip to content

fix(s2/Toast): offset toast above on-screen keyboard on mobile#9833

Open
mrlexcoder wants to merge 3 commits intoadobe:mainfrom
mrlexcoder:fix/toast-keyboard-offset
Open

fix(s2/Toast): offset toast above on-screen keyboard on mobile#9833
mrlexcoder wants to merge 3 commits intoadobe:mainfrom
mrlexcoder:fix/toast-keyboard-offset

Conversation

@mrlexcoder
Copy link

Summary

Fixes #9681

On mobile web, when the virtual keyboard is open, position: fixed elements are positioned relative to the layout viewport, not the visual viewport. This causes bottom-placed toasts to render underneath the keyboard.

Root cause

The S2 ToastContainer uses bottom: 16px from the CSS-in-JS toastRegion style. When the keyboard opens, the visual viewport shrinks but the layout viewport does not, so the toast stays anchored to the bottom of the full page — behind the keyboard.

Fix

Added a useKeyboardOffset() hook in packages/@react-spectrum/s2/src/Toast.tsx that:

  1. Listens to visualViewport resize and scroll events
  2. Computes the keyboard height as:
    keyboardHeight = window.innerHeight - (visualViewport.offsetTop + visualViewport.height)
    
  3. Returns 0 when visualViewport is unavailable (SSR / older browsers)

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 keyboard instead of 16px above the layout viewport bottom.

Behaviour

Scenario Before After
Desktop / no keyboard ✅ 16px from bottom ✅ unchanged
Mobile, keyboard closed ✅ 16px from bottom ✅ unchanged
Mobile, keyboard open ❌ hidden behind keyboard ✅ 16px above keyboard
SSR / no visualViewport API ✅ works ✅ unchanged (offset = 0)
placement='top' ✅ unaffected ✅ unaffected

Fixes adobe#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 <mrlexcoder@gmail.com>
@github-actions github-actions bot added the S2 label Mar 24, 2026
Copy link
Member

@snowystinger snowystinger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR.

Did you find out anything more about #9681 (comment) ? or why

height: 100vh;
height: 100dvh;

would or wouldn't work?

We have a hook for watching viewport size, called useViewportSize.

{...props}
ref={regionRef}
queue={queue}
style={isBottom && keyboardOffset > 0 ? {bottom: keyboardOffset + 16} : undefined}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this is really needed, then this should be built into the style macro, typically with a variable so that the property isn't inline
roughly

style={{'--keyboardOffset': isBottom ? keyboardOffset : 0}}

// off in the style macro
bottom: 'calc(var(--keyboardOffset) + 16)'

- 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 <mrlexcoder@gmail.com>
@mrlexcoder
Copy link
Author

Thanks for the review @snowystinger!

Re: 100dvh approach

100dvh (dynamic viewport height) does account for the keyboard on modern browsers, but it doesn't help here because the toast container uses position: fixed with a bottom offset — not a height. The container itself doesn't need to fill the viewport; it just needs its bottom anchor to sit above the keyboard. dvh would only help if we were sizing a full-height container, not positioning a small floating element from the bottom edge.

Additionally, dvh support is still inconsistent on older iOS Safari and some Android browsers, whereas visualViewport has broader support and is already used elsewhere in the codebase (e.g. useViewportSize, usePreventScroll).

Re: CSS variable approach

Updated the PR to use --keyboardOffset as a CSS variable fed into the style macro via calc(var(--keyboardOffset, 0px) + 16px), as suggested. The inline style now only sets the variable, not the bottom property directly.

@snowystinger
Copy link
Member

using 100dvh may be the wrong approach, but maybe you could do something like this potentially

bottom: calc(0dvh + 16px);

but it doesn't help here because the toast container uses position: fixed with a bottom offset — not a height.

This can be changed and was the a recommendation in the Issue.

dvh support is still inconsistent on older iOS Safari and some Android browsers

Which older Safari and Android, what're the lowest versions with support?

It looks like typescript is failing

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 <mrlexcoder@gmail.com>
@mrlexcoder
Copy link
Author

Thanks for the continued feedback @snowystinger!

Re: dvh approach

You're right — the position: fixed constraint can be changed. I've switched to a pure CSS solution with no JS at all:

bottom: calc(100vh - 100dvh + 16px)

When the 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 automatically sits 16px above the keyboard. On desktop where there is no keyboard, 100vh === 100dvh so it reduces to exactly 16px — identical to the previous behaviour. No JS, no event listeners, no CSS variables needed.

Re: dvh browser support

According to caniuse, dvh is supported from:

  • iOS Safari 15.4+ (released March 2022)
  • Chrome 108+ (released November 2022)
  • Samsung Internet 21+ (released 2023)
  • Firefox 101+ (released May 2022)

Anything older (iOS Safari ≤ 15.3, Chrome ≤ 107, Samsung Internet ≤ 20) will fall back to vh behaviour — the toast stays at 16px from the bottom of the layout viewport, which is the same as the current broken behaviour. So older browsers are no worse off, and modern browsers get the fix.

Re: TypeScript

Fixed — removed the as any cast by using the bracket syntax '[calc(100vh - 100dvh + 16px)]' which the style macro accepts as an arbitrary value, consistent with how other components in the codebase handle it (e.g. Picker.tsx, TreeView.tsx).

// 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)]',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is appearing too high off the bottom for me on iOS 26

maybe we can use

Suggested change
default: '[calc(100vh - 100dvh + 16px)]',
default: '[calc(env(safe-area-inset-bottom) + 16px)]',

though that may not work on previous iOS versions, @yihuiliao could you try this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

On mobile web, toasts render underneath the keyboard.

2 participants