KeyboardChatScrollView
KeyboardChatScrollView is a purpose-built component for chat application layouts. It handles keyboard appearance, interactive dismissal, and content repositioning with smooth 60/120 FPS animations — all the keyboard behaviors that chat apps need, but general-purpose components struggle to deliver.
Props
ScrollView Props
Inherits all ScrollView Props.
ScrollViewComponent
Custom component that will be used as a ScrollView. Default is ScrollView.
If you want to use ScrollView from react-native-gesture-handler you can pass it as a ScrollViewComponent prop.
import { ScrollView } from "react-native-gesture-handler";
<KeyboardChatScrollView ScrollViewComponent={ScrollView} />;
freeze
When true, freezes all keyboard-driven layout changes. This is useful when dismissing the keyboard to show a custom input view (such as an emoji picker or bottom sheet) — it prevents the chat content from shifting while the transition happens.
inverted
Set to true if your list uses the inverted prop (the standard pattern for chat-style lists where the newest messages appear at the bottom).
keyboardLiftBehavior
Controls how the chat content responds when the keyboard appears. Defaults to "always".
always
Content always lifts with the keyboard, keeping the bottom messages visible regardless of the current scroll position. This is the most common chat app behavior, used by Telegram, WhatsApp, and others.
whenAtEnd
Content lifts only when the scroll view is at the end (i.e., the last message is visible or near the bottom). If the user has scrolled up to read older messages, the keyboard won't push the content around. This matches the ChatGPT mobile app behavior.
persistent
Content lifts when the keyboard appears, but does not drop back when the keyboard hides. The scroll position stays where it was pushed to. This matches the Claude mobile app behavior.
never
Content never moves in response to the keyboard. The keyboard simply overlaps the chat. This matches the Perplexity app behavior.
offset
The distance between the bottom of the screen and the ScrollView. When the keyboard appears, the ScrollView will only push content by the effective distance (keyboardHeight - offset) instead of the full keyboard height. Defaults to 0.
This is useful when the input is not at the very bottom of the screen — for example, when the ScrollView sits above a safe area inset, bottom tabs, or any other fixed-height element. In that case, set offset to the height of the elements between the ScrollView and the bottom of the screen.
Usage with virtualized lists
KeyboardChatScrollView doesn't ship with built-in wrappers for third-party virtualized list libraries, but since all of them (FlatList, FlashList, LegendList) accept a custom scroll component, integration is straightforward.
First, create a wrapper component:
import React, { forwardRef } from "react";
import { KeyboardChatScrollView } from "react-native-keyboard-controller";
import type { ScrollViewProps } from "react-native";
import type { KeyboardChatScrollViewProps } from "react-native-keyboard-controller";
type Ref = React.ElementRef<typeof KeyboardChatScrollView>;
const VirtualizedListScrollView = forwardRef<
Ref,
ScrollViewProps & KeyboardChatScrollViewProps
>((props, ref) => {
return (
<KeyboardChatScrollView
ref={ref}
automaticallyAdjustContentInsets={false}
contentInsetAdjustmentBehavior="never"
{...props}
/>
);
});
export default VirtualizedListScrollView;
Then pass it to your list via renderScrollComponent:
<FlashList
ref={ref}
data={messages}
keyExtractor={(item) => item.text}
renderItem={({ item }) => <Message {...item} />}
renderScrollComponent={VirtualizedListScrollView}
/>
const memoList = useCallback(
(props: ScrollViewProps) => <VirtualizedListScrollView {...props} />,
[],
);
<FlatList
ref={ref}
data={messages}
keyExtractor={(item) => item.text}
renderItem={({ item }) => <Message {...item} />}
renderScrollComponent={memoList}
/>
<LegendList
ref={ref}
data={messages}
keyExtractor={(item) => item.text}
renderItem={({ item }) => <Message {...item} />}
renderScrollComponent={memoList}
/>
Example
Design principles
The key idea behind this component is that the ScrollView layout never changes. Instead of adjusting the layout or the ScrollView position, it changes the content position. To implement a chat interface we need to do two things: extend the scrollable range and adjust the scroll position.
Extending scroll range
When the keyboard appears we need to extend the scrollable range. On iOS this is achievable via contentInset. On Android it's less straightforward because there is no such property.
To bring support on Android, the ScrollView is wrapped with ClippingScrollViewDecorator — a custom view built in react-native-keyboard-controller that simulates contentInset behavior on Android by exposing two additional properties: contentInsetBottom and contentInsetTop. The usage looks like:
<ClippingScrollViewDecorator contentInsetTop={0} contentInsetBottom={0}>
<ScrollView style={{ flex: 1 }}>{/* ...content... */}</ScrollView>
</ClippingScrollViewDecorator>
The ClippingScrollViewDecorator should wrap the ScrollView. Whenever you change the contentInsetBottom or contentInsetTop properties, the contentInset will be automatically applied to the underlying ScrollView.
Adjusting scroll position
The second step is to adjust the scroll position. This is handled differently on iOS and Android.
iOS
On iOS we change the contentOffset property. It works well and without bugs, unlike on Android, so for Android we use a different approach.
Android
On Android we adjust the scroll position inside the onMove handler via the scrollTo method on the UI thread from a worklet.
Troubleshooting
Reanimated feature flag
KeyboardChatScrollView relies on a Reanimated commit hook internally. If you're using Reanimated < 4.3.0, you need to enable the USE_COMMIT_HOOK_ONLY_FOR_REACT_COMMITS feature flag in your package.json:
{
"reanimated": {
"staticFeatureFlags": {
"USE_COMMIT_HOOK_ONLY_FOR_REACT_COMMITS": true
}
}
}
After adding this, run pod install (iOS) and rebuild the app.
If you're on Reanimated 4.3.0+, this flag is enabled by default — no extra configuration needed.
If you don't enable this flag you'll see de-synchronized keyboard animation on Android/Fabric architecture.