Skip to main content
Version: Next

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.

When to use it?

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:

VirtualizedListScrollView.tsx
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
<FlashList
ref={ref}
data={messages}
keyExtractor={(item) => item.text}
renderItem={({ item }) => <Message {...item} />}
renderScrollComponent={VirtualizedListScrollView}
/>
FlatList/LegendList
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.

Reanimated 4.3.0+ relevance

If you're on Reanimated 4.3.0+, this flag is enabled by default — no extra configuration needed.

What it affects?

If you don't enable this flag you'll see de-synchronized keyboard animation on Android/Fabric architecture.