Keyboard and insets
Channel and Thread keep the composer above the keyboard by themselves, and the composer keeps clear of the home indicator. They need two numbers from your layout: how far below the top of the screen the chat starts, for the keyboard, and how much of the window sits above and below the chat, for the attachment picker. This page explains both, and what changes with safe areas and edge-to-edge on Android.
How the chat avoids the keyboard
Channel and Thread render their children inside KeyboardCompatibleView, a view adapted from React Native's KeyboardAvoidingView. It wraps the message list, the composer and the attachment picker. When the keyboard opens, it measures how much of the keyboard overlaps the view, and adds that much bottom padding, so the list shrinks and the composer rides on top of the keyboard. It listens to keyboardWillShow and keyboardWillHide on iOS, and to keyboardDidShow and keyboardDidHide on Android. While the app is in the background, it stops listening.
The view compares its own frame, which is relative to its parent, with the keyboard's position on the screen. keyboardVerticalOffset closes the gap between the two: it's the distance from the top of the screen to the top of the view that contains Channel.
Setting keyboardVerticalOffset
Set keyboardVerticalOffset on every Channel and Thread:
- Below a native navigation header, such as the React Navigation native stack's or Expo Router's, the screen's views start below the header. Pass the header's height, from
useHeaderHeight(). - With no header, or a header you render in JavaScript, the screen's views start at the top of the screen. Pass
0. This includes a header rendered as a child ofChannel, and aSafeAreaViewaroundChannel: the view measures the space they take as part of its own frame.
import { useHeaderHeight } from "@react-navigation/elements";
import type { Channel as StreamChannel } from "stream-chat";
import {
Channel,
MessageComposer,
MessageList,
} from "stream-chat-react-native";
export const ChannelScreen = ({ channel }: { channel: StreamChannel }) => {
const headerHeight = useHeaderHeight();
return (
<Channel
channel={channel}
keyboardVerticalOffset={headerHeight}
topInset={headerHeight}
>
<MessageList />
<MessageComposer />
</Channel>
);
};Use the same value on iOS and Android, and on the Thread of the same layout. When keyboardVerticalOffset isn't set, KeyboardCompatibleView uses its own default: 86.5 on iOS and -300 on Android. Neither matches a layout with no header, so pass 0 there rather than leaving it out.
A tab bar below the chat doesn't change the offset, which only depends on what is above the chat.
Android and edge-to-edge
On Android, what KeyboardCompatibleView does depends on whether the app draws edge-to-edge, which it reads from React Native's DeviceInfo constants:
- An app that isn't edge-to-edge, where the system bars take space from the window, relies on the system to resize the window when the keyboard opens (
android:windowSoftInputMode="adjustResize", as the React Native template sets it). The view adds no padding, so it doesn't add to the system's resize. - An edge-to-edge app draws behind the system bars, and the window doesn't resize for the keyboard. The view adds the padding itself, as on iOS. Edge-to-edge is the default for apps that target Android 15 (API level 35).
On Android, an overlap no taller than the status bar counts as no overlap.
The rest of the SDK reads the system bars through the safe area insets, which cover them in an edge-to-edge app. The message menu keeps within the top and bottom insets. When an Android device reports a bottom inset of 0, it keeps 60 points clear at the bottom instead.
Changing the keyboard view
Channel and Thread take these props for KeyboardCompatibleView:
| Prop | Type | Default | What it does |
|---|---|---|---|
keyboardVerticalOffset |
number |
86.5 iOS, -300 Android |
The distance from the top of the screen to the top of the view that contains Channel. |
keyboardBehavior |
"padding" | "position" | "height" |
"padding" |
How the view makes room: bottom padding, moving its content up, or reducing its height. |
disableKeyboardCompatibleView |
boolean |
false |
Leaves the view in place but stops it from reacting to the keyboard, for a screen that handles the keyboard itself. |
additionalKeyboardAvoidingViewProps |
Partial<KeyboardAvoidingViewProps> |
More props for the view, such as style. They're applied last, so they override the three above. |
With disableKeyboardCompatibleView, something else has to keep the composer above the keyboard, such as a KeyboardAvoidingView around the screen.
Replacing the view
KeyboardCompatibleView is a slot of WithComponents, and Channel and Thread render whatever component fills it, with the props above. The SDK doesn't use react-native-keyboard-controller itself. To move the chat with its keyboard animations, put its KeyboardAvoidingView in the slot:
import type { PropsWithChildren } from "react";
import {
KeyboardAvoidingView,
KeyboardProvider,
} from "react-native-keyboard-controller";
import { WithComponents } from "stream-chat-react-native";
// Read once, when WithComponents mounts, so the object stays the same.
const overrides = { KeyboardCompatibleView: KeyboardAvoidingView };
export const KeyboardSetup = ({ children }: PropsWithChildren) => (
<KeyboardProvider>
<WithComponents overrides={overrides}>{children}</WithComponents>
</KeyboardProvider>
);KeyboardSetup goes near the root of the app, around OverlayProvider. react-native-keyboard-controller needs its KeyboardProvider above every view that uses it; see its installation guide. Its KeyboardAvoidingView takes the same behavior, enabled and keyboardVerticalOffset as React Native's, so the props on Channel keep their meaning. Test the replacement on both platforms, with the attachment picker open and closed.
Whichever view fills the slot, a message closes the keyboard with Keyboard.dismiss() when it's long-pressed, and when it's tapped while dismissKeyboardOnMessageTouch is true, the default.
The keyboard in your own components
The default KeyboardCompatibleView provides KeyboardContext to everything inside Channel and Thread. Its dismissKeyboard() closes the keyboard and returns a promise that resolves once the keyboard has hidden, or returns nothing when the keyboard isn't open. Wait for it before opening something that needs the whole screen:
import { Button } from "react-native";
import { useKeyboardContext } from "stream-chat-react-native";
export const OpenDetailsButton = ({ onOpen }: { onOpen: () => void }) => {
const { dismissKeyboard } = useKeyboardContext();
return (
<Button
onPress={async () => {
await dismissKeyboard();
onOpen();
}}
title="Details"
/>
);
};Outside Channel and Thread, or with a replacement in the KeyboardCompatibleView slot, useKeyboardContext() returns React Native's Keyboard.dismiss, which returns right away.
useAfterKeyboardOpenCallback(callback) works the other way. The function it returns focuses the composer's input and runs callback once the keyboard is open, right away if it's already open or if the input isn't mounted. The default Edit action uses it, so the composer is in place before the message fills it. Call it inside Channel or Thread, because it reads the input from MessageInputContext.
The attachment picker
The attachment picker opens in a bottom sheet, rendered by Channel or Thread below their children, in place of the keyboard. The sheet is attachmentPickerBottomSheetHeight tall: 333 by default, or 72 with disableAttachmentPicker, when only the selection bar shows. While it's open, the composer moves up by the same height. Opening the keyboard closes the picker.
The sheet sizes itself against the whole window, so it has to know how much of the window is outside the chat. Channel and Thread take two insets for this, both 0 by default:
topInsetis the space above the chat that isn't part of its views. Below a native navigation header, pass the header's height, the same value askeyboardVerticalOffset. Without it, the sheet measures from the top of the window, header included, and can't open to its full height. With no header, or a header rendered in JavaScript, leave it at0: the header is already part of the chat's layout, and the sheet would open too low.bottomInsetis the space the sheet keeps free at the bottom. While the picker is open, the composer moves up by the sheet's height minusbottomInset. The composer already keeps clear of the bottom safe area by itself, so most screens leave it at0.
Both can change after Channel mounts, for example when the header hides. Pass the new value as a prop. useAttachmentPickerContext() reads the current values, and the picker's other props are on Attachment picker.
Safe areas
The SDK reads safe area insets from react-native-safe-area-context, so the app needs a SafeAreaProvider at its root. A navigation library usually renders one, and the Quick start adds one too.
The composer keeps clear of the bottom of the screen by itself. While the keyboard and the attachment picker are closed, its bottom padding is the bottom safe area inset, for the home indicator or the navigation bar, and 16 points when that inset is 0. While either is open, the padding is 16 points. So don't pad the bottom of the chat again:
- Leave out the bottom edge of a
SafeAreaViewaroundChannel. TheSampleAppusesedges={["left", "right"]}below its header. - Don't wrap
MessageComposerin aSafeAreaViewof its own.
With no navigation header, the top of the chat needs the top inset. A SafeAreaView with edges={["top", "left", "right"]} around Channel provides it, and keyboardVerticalOffset stays 0:
import { StyleSheet } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import type { Channel as StreamChannel } from "stream-chat";
import {
Channel,
MessageComposer,
MessageList,
} from "stream-chat-react-native";
export const FullScreenChannel = ({ channel }: { channel: StreamChannel }) => (
<SafeAreaView edges={["top", "left", "right"]} style={styles.container}>
<Channel channel={channel} keyboardVerticalOffset={0}>
<MessageList />
<MessageComposer />
</Channel>
</SafeAreaView>
);
const styles = StyleSheet.create({
container: { flex: 1 },
});The image gallery and the bottom sheets the SDK opens, such as the message's reactions, read the insets themselves.