Skip to content

fix(modal): move dialogs above the on-screen keyboard - #5045

Open
aliyilmaztech wants to merge 1 commit into
callstack:mainfrom
aliyilmaztech:fix/modal-keyboard-avoidance
Open

fix(modal): move dialogs above the on-screen keyboard#5045
aliyilmaztech wants to merge 1 commit into
callstack:mainfrom
aliyilmaztech:fix/modal-keyboard-avoidance

Conversation

@aliyilmaztech

@aliyilmaztech aliyilmaztech commented Aug 12, 2026

Copy link
Copy Markdown

Motivation

Components rendered in a Portal, such as Modal and Dialog, are not moved out of the way of the on-screen keyboard.

On Android this used to be handled by the system: React Native apps set android:windowSoftInputMode="adjustResize", so the whole window was shrunk when the keyboard appeared and the modal, which fills the window, was re-centered in the remaining space. This no longer happens in edge-to-edge mode (opt-in via edgeToEdgeEnabled, enforced since Android 15), where the keyboard is reported as an inset that the app has to handle itself, so the keyboard covers the dialog and its actions. On iOS the window is never resized, so this never worked there at all.

KeyboardAvoidingView can't be used by the app as a workaround here, because the modal is rendered through a Portal, outside of the app's own view hierarchy.

This PR makes Modal track the keyboard and pad its wrapper by the part of it that the keyboard covers, so the content stays centered in the visible area. Dialog gets the fix for free, since it renders a Modal.

A few notes on the implementation:

  • The overlap is the distance between the measured bottom edge of the wrapper (onLayout) and the top edge of the keyboard (endCoordinates.screenY), which is the same relation KeyboardAvoidingView uses. Both are relative to the window, so windows which are still resized by the system need no special casing: their wrapper is already laid out above the keyboard, which puts the overlap at or below zero. Nothing is assumed about the safe area insets either, so overriding marginTop/marginBottom through the style prop keeps working.
  • keyboardWillHide/keyboardDidHide clear the metrics instead of feeding them back into the calculation, which is what makes KeyboardAvoidingView keep a stale offset on Android in edge-to-edge mode (Fix Keyboard events and KeyboardAvoidingView on Android (edge-to-edge) react/react-native#55855).
  • Nothing is subscribed or computed while the modal is hidden, and the keyboard is not tracked on the web, where no keyboard events are emitted.
  • The padding change is scheduled with Keyboard.scheduleLayoutAnimation, so it follows the keyboard animation curve on iOS.

Related issue

Fixes #5021
Fixes #4218

Test plan

Automated: new unit tests in src/components/__tests__/Modal.test.tsx cover a window that is not resized (padding is applied), a window that is resized by the system (padding is not applied), overridden safe area insets, and hiding the keyboard (padding is restored). yarn test, yarn lint and yarn typecheck pass.

Manual, using the new Dialog -> With text input example:

  1. Run the example app on Android 15/16, or on an older Android with edgeToEdgeEnabled=true in android/gradle.properties.
  2. Open the Dialog example and press With text input.
  3. Focus the text field: the dialog moves above the keyboard and its actions stay reachable; dismissing the keyboard restores its position.
  4. Repeat with edgeToEdgeEnabled=false to confirm the previous behavior is unchanged, and on iOS, where the dialog now moves as well.

Copilot AI lite review requested due to automatic review settings August 12, 2026 14:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds keyboard-aware layout behavior for Modal (and therefore Dialog) rendered via Portal, addressing edge-to-edge Android and iOS where the system doesn’t reliably resize the window when the on-screen keyboard appears.

Changes:

  • Introduces a new useKeyboardOverlap hook to compute how much the keyboard covers a full-screen container.
  • Updates Modal to pad its wrapper by the computed keyboard overlap so content stays centered/accessible.
  • Adds unit tests and a new example dialog demonstrating the behavior with a TextInput.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/utils/useKeyboardOverlap.tsx New hook that listens to keyboard + dimension changes and computes keyboard overlap for padding.
src/components/Modal.tsx Applies paddingBottom based on keyboard overlap and measures wrapper height for resize detection.
src/components/tests/Modal.test.tsx Adds unit tests validating padding behavior across resized vs non-resized windows and hide/show transitions.
example/src/Examples/Dialogs/index.tsx Exports the new dialog example.
example/src/Examples/Dialogs/DialogWithTextInput.tsx New example showcasing a dialog with a text input to validate keyboard avoidance.
example/src/Examples/DialogExample.tsx Wires the new example into the dialog examples screen.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/utils/useKeyboardOverlap.tsx Outdated
Comment on lines +27 to +28
const getCurrentMetrics = () =>
(Keyboard.isVisible() ? Keyboard.metrics() : null) ?? null;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks — I checked the versions before changing this, and I don't think the guard would be reachable.

Keyboard.isVisible() and Keyboard.metrics() both shipped in React Native 0.65. This package requires react-native-reanimated >= 4.3.0 as a peer dependency, and Reanimated 4 requires the New Architecture, i.e. React Native 0.78+, so the effective floor here is far above 0.65 (the react-native: "*" peer range is nominal). A typeof guard would be dead code, and the as any cast would drop the KeyboardMetrics | null return type that metrics.screenY relies on below.

There is one real gap though, which is not about old React Native: react-native-web's Keyboard shim implements isVisible, addListener and dismiss, but not metrics or scheduleLayoutAnimation. Today that is harmless because its isVisible() always returns false and it never emits keyboard events, but the code relied on that implicitly. I made it explicit with an optional call and a comment, which keeps the types intact:

const getCurrentMetrics = () =>
  // `metrics` is not implemented by react-native-web
  (Keyboard.isVisible() ? Keyboard.metrics?.() : null) ?? null;

Comment thread src/utils/useKeyboardOverlap.tsx Outdated
Comment on lines +63 to +69
React.useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ screen }) =>
setScreenHeight(screen.height)
);

return () => subscription.remove();
}, []);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — the subscription is gone entirely now.

The overlap is calculated from the measured bottom edge of the wrapper and the keyboard's screenY, both relative to the window, so the screen height is no longer part of the calculation and there is nothing to keep in sync with Dimensions. The only remaining effect is the keyboard subscription, which is already gated behind enabled.

Comment thread src/components/Modal.tsx
Comment on lines 115 to +125
const { top, bottom } = useSafeAreaInsets();
const opacity = useAnimatedValue(visible ? 1 : 0);
const [visibleInternal, setVisibleInternal] = React.useState(visible);
const [wrapperHeight, setWrapperHeight] = React.useState<number | null>(null);

const keyboardOverlap = useKeyboardOverlap({
enabled: visibleInternal,
containerHeight: wrapperHeight,
topInset: top,
bottomInset: bottom,
});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed, and fixed — but by removing the assumption rather than by flattening the style.

useKeyboardOverlap no longer receives insets at all. Modal reports the wrapper's y + height from onLayout, and the overlap is the distance between that edge and the keyboard's screenY, so whatever margins end up applied (safe area insets, an override through style, or something else) are already reflected in the measured layout. Reading the flattened style would only cover numeric literals and would still be a second source of truth next to the actual layout.

This also removed the heuristic that detected system-resized windows by comparing the wrapper height with the available screen height: since both edges are now relative to the window, a resized window simply yields an overlap of zero.

Added a test that overrides marginBottom through style and asserts the padding follows the wrapper instead of the safe area inset.

Comment on lines +78 to +101
const onShow = (event: KeyboardEvent) => {
setMetrics(event.endCoordinates);
Keyboard.scheduleLayoutAnimation(event);
};

const onHide = (event: KeyboardEvent) => {
setMetrics(null);
Keyboard.scheduleLayoutAnimation(event);
};

// `keyboardWillShow` and `keyboardWillHide` are not emitted on Android.
const subscriptions =
Platform.OS === 'ios'
? [
Keyboard.addListener('keyboardWillShow', onShow),
Keyboard.addListener('keyboardWillHide', onHide),
]
: [
Keyboard.addListener('keyboardDidShow', onShow),
Keyboard.addListener('keyboardDidHide', onHide),
];

return () => subscriptions.forEach((subscription) => subscription.remove());
}, [enabled]);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

scheduleLayoutAnimation is now called as Keyboard.scheduleLayoutAnimation?.(event) with a comment, since react-native-web doesn't implement it.

I left the remove() calls as they are. The removeListener fallback in useIsKeyboardShown predates React Native 0.65, where EmitterSubscription.remove() became the supported way to unsubscribe; removeListener was deprecated in 0.65 and removed in 0.70, so the fallback path would only run on versions this package can no longer be installed on (react-native-reanimated >= 4.3.0 requires the New Architecture, i.e. React Native 0.78+). Note that useIsKeyboardShown still carries @ts-expect-error annotations for those calls, which is a sign that branch is legacy rather than something new code should copy.

Copilot AI review requested due to automatic review settings August 12, 2026 20:32
@aliyilmaztech
aliyilmaztech force-pushed the fix/modal-keyboard-avoidance branch from 5e69137 to a550751 Compare August 12, 2026 20:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Suppressed comments (5)

src/utils/useKeyboardOverlap.tsx:20

  • getCurrentMetrics calls Keyboard.isVisible() unconditionally. On platforms/React Native versions where isVisible isn’t implemented (notably web), this will throw at runtime. Guard the call with optional chaining (and access metrics through the same guarded object).
const getCurrentMetrics = () =>
  // `metrics` is not implemented by react-native-web
  (Keyboard.isVisible() ? Keyboard.metrics?.() : null) ?? null;

src/utils/useKeyboardOverlap.tsx:88

  • Overlap is currently computed only from metrics.screenY, but the PR description says metrics differ across platforms/versions and you need to consider keyboard height too. Compute the keyboard’s top edge as the minimum of screenY and screenHeight - height, then derive overlap from that.
  return Math.max(0, containerBottom - metrics.screenY);
}

src/utils/useKeyboardOverlap.tsx:66

  • Cleanup assumes every subscription has a .remove() method. Elsewhere in the codebase (e.g. src/utils/useIsKeyboardShown.tsx) you keep a fallback to deprecated Keyboard.removeListener for older React Native versions. This hook should do the same to avoid crashes on older RN implementations.
    // `keyboardWillShow` and `keyboardWillHide` are not emitted on Android.
    const subscriptions =
      Platform.OS === 'ios'
        ? [
            Keyboard.addListener('keyboardWillShow', onShow),

src/utils/useKeyboardOverlap.tsx:47

  • The PR description says the keyboard should not be tracked on web, but the hook will still subscribe when enabled is true (and may rely on missing Keyboard APIs). Consider bailing out early for Platform.OS === 'web'.
    if (!enabled) {
      return undefined;
    }

src/utils/useKeyboardOverlap.tsx:2

  • The PR description notes overlap should be derived from both screenY and the keyboard height (whichever yields a larger overlap), but the current implementation only uses screenY. Importing Dimensions here is needed to compute the alternative top edge (screenHeight - height).

This issue also appears on line 87 of the same file.

import { Keyboard, Platform } from 'react-native';

Comment thread src/utils/useKeyboardOverlap.tsx Outdated
import { Keyboard, Platform } from 'react-native';
import type { KeyboardEvent } from 'react-native';

type KeyboardMetrics = NonNullable<ReturnType<typeof Keyboard.metrics>>;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Applied — KeyboardEvent['endCoordinates'] is the better spelling here, so thanks.

To be precise about the reasoning: the two are the same type (KeyboardEvent.endCoordinates is declared as KeyboardMetrics in React Native's own definitions, which don't export that alias), and the version concern doesn't apply on this branch — Keyboard.metrics() has been typed since React Native 0.65, while this package requires react-native-reanimated >= 4.3.0, i.e. the New Architecture and React Native 0.78+.

The change is still worth making on clarity grounds: the state holds exactly what keyboardWillShow/keyboardDidShow deliver in event.endCoordinates, so deriving it from KeyboardEvent, which is already imported for the handler signatures, says that directly and drops the NonNullable<ReturnType<…>> indirection.

The Keyboard.metrics?.() call itself stays as is: it's needed to pick up a keyboard that is already open when the modal mounts, and the optional call is there because react-native-web doesn't implement it.

Copilot AI review requested due to automatic review settings August 12, 2026 20:41
@aliyilmaztech
aliyilmaztech force-pushed the fix/modal-keyboard-avoidance branch from a550751 to 4663c87 Compare August 12, 2026 20:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/components/tests/Modal.test.tsx:617

  • These tests emit only iOS keyboard events (keyboardWillShow/keyboardWillHide), but useKeyboardOverlap subscribes to keyboardDidShow/keyboardDidHide on non-iOS platforms. Since other test files mutate Platform.OS without always restoring it, this block can become order-dependent/flaky. Emit both Will* and Did* events here (or explicitly set/restore Platform.OS) so the listener is triggered deterministically.
    const showKeyboard = async () => {
      await act(() => {
        DeviceEventEmitter.emit('keyboardWillShow', {
          endCoordinates: {
            screenX: 0,
            screenY: screenHeight - KEYBOARD_HEIGHT,
            width: 750,
            height: KEYBOARD_HEIGHT,
          },
        });
      });
    };

    const hideKeyboard = async () => {
      await act(() => {
        DeviceEventEmitter.emit('keyboardWillHide', {
          endCoordinates: {
            screenX: 0,
            screenY: screenHeight,
            width: 750,
            height: 0,
          },
        });
      });
    };

@aliyilmaztech
aliyilmaztech force-pushed the fix/modal-keyboard-avoidance branch from 4663c87 to 388e5e4 Compare August 12, 2026 20:48
Copilot AI review requested due to automatic review settings August 12, 2026 20:48
@aliyilmaztech

Copy link
Copy Markdown
Author

One more note on the review feedback about the keyboard tests (the comment on Modal.test.tsx about Platform.OS), since it was raised on a block that has now moved.

Jest gives every test file its own module registry, so a Platform.OS mutation in another file cannot reach this one — that's also why the existing tests in Dialog.test.tsx, ProgressBar.test.tsx and others get away with setting it without restoring. Within this file nothing touched Platform.OS, so the block was deterministically running the iOS branch.

That was still a real gap: the bug this PR fixes is an Android one, and the Android listeners (keyboardDidShow/keyboardDidHide) were not exercised at all. Emitting both Will* and Did* events would have hidden that, since it would pass even if the component subscribed to the wrong pair, so I parameterised the block by platform instead:

// `keyboardWillShow` and `keyboardWillHide` are not emitted on Android
const keyboardEventsPerPlatform: Array<[typeof Platform.OS, string, string]> = [
  ['ios', 'keyboardWillShow', 'keyboardWillHide'],
  ['android', 'keyboardDidShow', 'keyboardDidHide'],
];

describe.each(keyboardEventsPerPlatform)(
  'when the on-screen keyboard is shown on %s',
  (platform, showEvent, hideEvent) => {
    const originalPlatform = Platform.OS;

    beforeAll(() => {
      Platform.OS = platform;
    });

    afterAll(() => {
      Platform.OS = originalPlatform;
    });
    

All four cases now run on both platforms, and Platform.OS is restored afterwards so the rest of the file is unaffected.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/utils/useKeyboardOverlap.tsx:42

  • useState(getCurrentMetrics) runs on the initial render even when enabled is false, which contradicts the PR note that nothing is computed while hidden and needlessly reads keyboard state for offscreen containers. You can keep the no-flicker behavior when initially enabled by lazy-initializing based on enabled and otherwise starting at null.
  const [metrics, setMetrics] = React.useState<KeyboardMetrics | null>(
    getCurrentMetrics
  );

Components rendered in a `Portal`, such as `Modal` and `Dialog`, used to be
kept above the keyboard by the system: on Android `windowSoftInputMode`
was set to `adjustResize`, which shrank the whole window. That no longer
happens in edge-to-edge mode, which is enforced since Android 15, where
the keyboard is reported as an inset that has to be handled by the app,
and it never happened on iOS.

Track the keyboard in `Modal` and pad the wrapper by the distance between
its measured bottom edge and the top edge of the keyboard, so the content
stays centered in the visible area.

Both edges are relative to the window, so windows which are still resized
by the system need no special casing: their wrapper is already laid out
above the keyboard, which puts the overlap at or below zero.

Fixes callstack#5021
Fixes callstack#4218

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aliyilmaztech
aliyilmaztech force-pushed the fix/modal-keyboard-avoidance branch from 388e5e4 to ff4bd58 Compare August 12, 2026 20:56
Copilot AI review requested due to automatic review settings August 12, 2026 20:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

In edge-to-edge Android, Dialogs do not move when keyboard is opened Modals on iOS do not respond to the presence of the on-screen keyboard

2 participants