@kesha-antonov/react-native-chat
v4.3.0
Published
The most complete chat UI for React Native & Web - streaming AI messages, emoji reactions, replies, quick replies, and full customization. TypeScript-first, Expo-ready. A maintained react-native-gifted-chat successor.
Maintainers
Readme
✨ Features
Actively maintained, New Architecture ready, and built for the latest Reanimated/Gesture Handler. A modern, themeable, drop-in successor to
react-native-gifted-chat. See what is new ▸
- 🌗 Modern UI, Dark Mode & Theming - A clean, modern default look with a full light/dark theme system, runtime theme switching, and every token overridable
- 🤖 Streaming (AI) Messages - Token-by-token streamed replies with a typing cursor and stop control
- 🎥 Video & Audio Messages - Real inline playback (optional
expo-video/expo-audio) with a graceful tappable fallback - no more "implement it yourself" - 🎙️ Voice & Video Recording - Telegram-style hold-to-record voice notes and camera video messages (optional, opt-in)
- 📍 Location Messages - Map card that opens the system maps app on tap
- 🎨 Fully Customizable - Override any component with your own implementation
- 📎 Composer Actions - Attach photos, files, or trigger custom actions
- ↩️ Reply to Messages - Swipe-to-reply with reply preview and message threading
- ⏮️ Load Earlier Messages - Infinite scroll with pagination support
- 📋 Copy to Clipboard - Long-press messages to copy text
- 🔗 Smart Link Parsing - Auto-detect URLs, emails, phone numbers, hashtags, mentions
- 👤 Avatars - User initials or custom avatar images
- 🌍 Localized Dates - Full i18n support via Day.js
- ⌨️ Keyboard Handling - Smart keyboard avoidance for all platforms
- 💬 System Messages - Display system notifications in chat
- ⚡ Quick Replies - Bot-style quick reply buttons
- 😀 Emoji Reactions - Long-press to react, with reaction pills and an optional full emoji browser
- ✍️ Typing Indicator - Show when users are typing
- ✅ Message Status - Tick indicators for sent/delivered/read states
- ⬇️ Scroll to Bottom - Quick navigation button
- 🌐 Web Support - Works with react-native-web
- 📱 Expo Support - Easy integration with Expo projects
- 📝 TypeScript - Complete TypeScript definitions included
🆕 What's new vs react-native-gifted-chat
This library is kept in sync with upstream react-native-gifted-chat's latest master, so you keep everything it already has - New Architecture support, the animated sticky day header, Reanimated 3/4, Gesture Handler, TypeScript - and the same IMessage model and prop names. Moving over is a package swap plus a rename (see Migrating).
On top of that, this fork adds the features people kept asking upstream for, plus a modern look out of the box:
| Added in this fork | react-native-gifted-chat | @kesha-antonov/react-native-chat |
| --- | :---: | :---: |
| Active maintenance | 💤 sporadic | ✅ active |
| Modern default UI (Telegram-inspired) | dated 2020 look | ✅ modern, fully overridable |
| Light/Dark theme system | per-component color props | ✅ theme / darkTheme tokens, runtime switch |
| Streaming (AI) messages | ❌ | ✅ token-by-token + typing cursor |
| Emoji reactions | ❌ | ✅ long-press picker + reaction pills |
| Swipe-to-reply + reply preview | ❌ | ✅ built in |
| Video / audio messages | "not implemented, render your own" | ✅ inline players + tappable fallback |
| Voice notes (hold-to-record + waveform) | ❌ | ✅ optional, Telegram-style |
| Video messages (round camera notes) | ❌ | ✅ optional, Telegram-style |
| Location messages | ❌ ignored | ✅ map card → opens system maps |
| Bubble tails + tighter message grouping | flat bubbles | ✅ |
Everything new is non-breaking and opt-in: keep passing the same props you do today and you simply get the modern look and the extra features for free. The media/recording features only activate when you install their optional peer deps and enable them - nobody is forced to add a single dependency.
Theming in one line
// Modern defaults out of the box, or override any token (light + dark):
<Chat
theme={{ colors: { accent: '#3390EC', outgoingBubble: '#EFFEDE' } }}
darkTheme={{ colors: { background: '#0E1621' } }}
{...props}
/>Voice, video and location
<Chat
audioRecording={{ isEnabled: true }} // hold the mic to record a voice note (needs expo-audio)
videoRecording={{ isEnabled: true }} // record a video message (needs expo-image-picker)
// location messages render automatically for any IMessage with a `location`
{...props}
/># Optional inline media playback + recording:
npx expo install expo-video expo-audio expo-image-pickerCustom icons (e.g. Lucide)
Built-in icons are the official Lucide glyphs, rendered via the optional react-native-svg peer when it is installed, or drawn with Views (no dependency) otherwise. Override any of them via the icons prop - for example with lucide-react-native - and the built-in icon is used for anything you don't override:
import { Send, Mic } from 'lucide-react-native'
<Chat
icons={{
send: ({ color, size }) => <Send color={color} size={size} />,
mic: ({ color, size }) => <Mic color={color} size={size} />,
}}
{...props}
/>Overridable names: send, mic, camera, play, pause, check, checkAll, clock, pin, plus, close, chevronLeft, chevronDown, emoji, paperclip, reply, pencil, lock, trash.
📖 Table of Contents
- Features
- What's new vs react-native-gifted-chat
- Requirements
- Installation
- Migrating from react-native-gifted-chat
- Usage
- Props Reference
- Data Structure
- Platform Notes
- Performance
- Example App
- Troubleshooting
- Contributing
- Authors
- License
📋 Requirements
| Requirement | Version | |-------------|---------| | React Native | >= 0.70.0 | | iOS | >= 13.4 | | Android | API 21+ (Android 5.0) | | Expo | SDK 50+ | | TypeScript | >= 5.0 (optional) |
📦 Installation
Expo Projects
npx expo install @kesha-antonov/react-native-chat react-native-reanimated react-native-gesture-handler react-native-safe-area-context react-native-keyboard-controllerBare React Native Projects
Step 1: Install the packages
Using yarn:
yarn add @kesha-antonov/react-native-chat react-native-reanimated react-native-gesture-handler react-native-safe-area-context react-native-keyboard-controllerUsing npm:
npm install --save @kesha-antonov/react-native-chat react-native-reanimated react-native-gesture-handler react-native-safe-area-context react-native-keyboard-controllerStep 2: Install iOS pods
npx pod-installStep 3: Configure react-native-reanimated
Follow the react-native-reanimated installation guide to add the Babel plugin.
🔄 Migrating from react-native-gifted-chat
This library is a rebranded continuation of react-native-gifted-chat, built from its latest master. The API, props, and IMessage model are unchanged - migrating is a package swap plus renaming the GiftedChat* identifiers.
yarn remove react-native-gifted-chat
yarn add @kesha-antonov/react-native-chat| react-native-gifted-chat | @kesha-antonov/react-native-chat |
| --- | --- |
| react-native-gifted-chat | @kesha-antonov/react-native-chat |
| GiftedChat | Chat |
| GiftedAvatar | ChatAvatar |
| GiftedChatContext | ChatContext |
| IMessage, User, useChatContext, … | unchanged |
See the full guide (codemod included) in docs/MIGRATION.md.
🚀 Usage
Basic Example
import React, { useState, useCallback, useEffect } from 'react'
import { Chat } from '@kesha-antonov/react-native-chat'
export function Example() {
const [messages, setMessages] = useState([])
useEffect(() => {
setMessages([
{
_id: 1,
text: 'Hello developer',
createdAt: new Date(),
user: {
_id: 2,
name: 'John Doe',
avatar: 'https://placeimg.com/140/140/any',
},
},
])
}, [])
const onSend = useCallback((messages = []) => {
setMessages(previousMessages =>
Chat.append(previousMessages, messages),
)
}, [])
return (
<Chat
messages={messages}
onSend={messages => onSend(messages)}
user={{
_id: 1,
}}
/>
)
}💡 Tip: Check out more examples in the
exampledirectory including Slack-style messages, quick replies, and custom components.
📊 Data Structure
Messages, system messages, and quick replies follow the structure defined in Models.ts.
interface IMessage {
_id: string | number
text: string
createdAt: Date | number
user: User
image?: string
video?: string
audio?: string
system?: boolean
sent?: boolean
received?: boolean
pending?: boolean
/** True while the text is still streaming in (shows a typing cursor) */
streaming?: boolean
quickReplies?: QuickReplies
replyMessage?: ReplyMessage
reactions?: MessageReaction[]
location?: {
latitude: number
longitude: number
}
}
interface ReplyMessage {
_id: string | number
text: string
user: User
image?: string
audio?: string
}
interface MessageReaction {
emoji: string
userIds: (string | number)[]
}
interface User {
_id: string | number
name?: string
avatar?: string | number | (() => React.ReactNode)
}📖 Props Reference
Core Configuration
messages(Array) - Messages to displayuser(Object) - User sending the messages:{ _id, name, avatar }onSend(Function) - Callback when sending a messagemessageIdGenerator(Function) - Generate an id for new messages. Defaults to a simple random string generator.locale(String) - Locale to localize the dates. You need first to import the locale you need (ie.require('dayjs/locale/de')orimport 'dayjs/locale/fr')colorScheme('light' | 'dark') - Force color scheme (light/dark mode). When set to'light'or'dark', it overrides the system color scheme. Whenundefined, it uses the system color scheme. Default isundefined.theme(Object) - Override the default light theme tokens (colors/radii/spacing/typography/avatar/sendButton/composer/voice). Deep-merged overdefaultLightTheme; any subset is allowed. See Theming & Dark Mode.darkTheme(Object) - Same astheme, applied when the resolved color scheme is dark (deep-merged overdefaultDarkTheme).icons(Object) - Icon override registry. Supply a render function for any built-in icon to replace it (e.g. withlucide-react-native). See Custom icons.labels(Object) - Override any UI string. See Localization (i18n).
Refs
messagesContainerRef(FlatList ref) - Ref to the flatlisttextInputRef(TextInput ref) - Ref to the text input
Keyboard & Layout
keyboardProviderProps(Object) - Props to be passed to theKeyboardProviderfor keyboard handling. No defaults are applied - in particular Chat does not setstatusBarTranslucent/navigationBarTranslucent, because on Android those change the activity window and the change outlives the chat screen (#2755).react-native-keyboard-controllerturns them on by itself when the app is genuinely edge-to-edge, so there is nothing to set in a normal app.Only used when Chat mounts the provider itself. If your app already mounts a
KeyboardProvider(the setupreact-native-keyboard-controllerrecommends - once, at the root), Chat detects it and reuses it instead of nesting a second one, and this prop is ignored. Configure the provider where you mount it.disableKeyboardProvider(Bool) - Skip the built-inKeyboardProviderentirely; default isfalse. You do not need this just because your app mounts its own provider - that case is detected and reused. Reach for it only to opt out completely, e.g. when the provider's edge-to-edge behavior causes layout shift or a header jump on Android/Expo.keyboardAvoidingViewProps(Object) - Props to be passed to theKeyboardAvoidingView. See keyboardVerticalOffset below for proper keyboard handling.isAlignedTop(boolean | 'auto') - Where the bubbles sit while the whole conversation fits on screen; once it is taller than the list this has no effect.false(default) keeps the usual bottom-anchored chat,truepins the messages to the top, and'auto'pins them to the top while the keyboard is closed and re-anchors them to the bottom while it is open - so a short conversation starts under the header and moves above the keyboard when the composer is focused (#2736). Works with eitherisInvertedsetting; ignored whenisFlashListEnabledis set, since FlashList positions its own items.isInverted(Bool) - Reverses display order ofmessages; default istrue
Understanding keyboardVerticalOffset
keyboardVerticalOffset tells the KeyboardAvoidingView how far down the screen its container starts. That distance depends on the navigation header and on anything else you render above the chat.
You do not normally need to set it. Chat measures its own position on screen and uses that, so the input toolbar sits on the keyboard whether the chat is full-screen or under a navigation header. The measurement comes from the SafeAreaProvider frame and updates on rotation and layout changes.
Pass your own value only to add extra space above the keyboard - it replaces the measured one:
<Chat keyboardAvoidingViewProps={{ keyboardVerticalOffset: headerHeight + 16 }} />If you do, sanity-check it on device: a toolbar behind the keyboard means the value is too small, a gap above the keyboard means it is too large. useHeaderHeight() is the usual source, but some navigator setups report a value that does not match the header you actually render.
Upgrading from 4.1.0 or earlier: the default used to be
insets.top, which could not account for a navigation header - so most apps passeduseHeaderHeight()to compensate. That is no longer needed; drop it and let Chat measure, or the toolbar will float above the keyboard by the header height.
Text Input & Composer
text(String) - Input text; default isundefined, but if specified, it will override Chat's internal state. Useful for managing text state outside of Chat (e.g. with Redux). Don't forget to implementtextInputProps.onChangeTextto update the text state.initialText(String) - Initial text to display in the input fieldisSendButtonAlwaysVisible(Bool) - Always show send button in input text composer; defaultfalse, show only when text input is not emptyisTextOptional(Bool) - Allow sending messages without text (useful for media-only messages); defaultfalse. Use withisSendButtonAlwaysVisiblefor media attachments.isMultiline(Bool) - Whether the composer accepts multiple lines; defaulttrue. Withtruethe return key inserts a newline and you send with the send button. Setfalsefor a single-line composer whose return key sends the message (the keyboard's return key becomes "send" and stays open afterwards).renderInputToolbar(Component | Function) - Custom message composer containerrenderComposer(Component | Function) - Custom text input message composerrenderSend(Component | Function) - Custom send button; you can pass children to the originalSendcomponent quite easily, for example, to use a custom icon (example)renderActions(Component | Function) - Custom action button on the left of the message composerrenderAccessory(Component | Function) - Custom second line of actions below the message composeronPressEmoji(Function) - Callback for the optional emoji button on the left of the composer field. When omitted, the emoji button is hidden.audioRecording(Object) - Enable Telegram-style hold-to-record voice notes.{ isEnabled, minDurationMs?, onError? }. Requires the optionalexpo-audiopeer (andreact-native-audio-apifor the playback waveform); the mic button is hidden when it is absent.videoRecording(Object) - Enable record-and-send video messages.{ isEnabled, maxDuration?, onError? }. Usesreact-native-vision-camerafor round camera notes, falling back toexpo-image-picker's system camera.textInputProps(Object) - props to be passed to the<TextInput>.
Composer height - there are no height props. The composer starts one line tall and grows with its content. Constrain it through
textInputProps.style(e.g.{ maxHeight: 120 }), which is applied after the measured height and wins.
Actions & Action Sheet
actions(Array) - Action options for the composer "+" button. Array of{ title, action }; addicon(and optionalcolor) to an action to render a Telegram-style attachment grid (tiles) instead of a list. Opens the built-in themedAttachmentSheet- no extra dependency.onPressActionButton(Function) - Callback when the "+" button is pressed (if set, the built-inAttachmentSheetis not shown)actionSheet(Function) - Escape hatch for a custom system action sheet. The bundled@expo/react-native-action-sheetdependency was removed, socontext.actionSheet()defaults to a no-op; pass your own implementation (withActionSheetProviderin your tree) if you relied on it.actionSheetOptionTintColor(String) - Tint color for action labels in the attachment sheet
Messages & Message Container
messagesContainerStyle(Object) - Custom style for the messages containerrenderMessage(Component | Function) - Custom message containerrenderLoading(Component | Function) - Render a loading view when initializingrenderChatEmpty(Component | Function) - Custom component to render in the ListView when messages are emptyrenderChatFooter(Component | Function) - Custom component to render below the MessagesContainer (separate from the ListView)listProps(Object) - Extra props to be passed to the messages<FlatList>. Supports all FlatList props includingmaintainVisibleContentPositionfor keeping scroll position when new messages arrive (useful for AI chatbots).isFlashListEnabled(Bool) - Render messages with@shopify/flash-listv2 instead ofFlatList; default isfalse. See FlashList.
Message Bubbles & Content
renderBubble(Component | Function(props: BubbleProps)) - Custom message bubble. Receives BubbleProps as parameter.renderMessageText(Component | Function) - Custom message textrenderMessageImage(Component | Function) - Custom message imagerenderMessageVideo(Component | Function) - Custom message videorenderMessageAudio(Component | Function) - Custom message audiorenderMessageLocation(Component | Function) - Custom renderer forIMessage.location; defaults to a map card that opens the system maps app on tapmessageActions(Array | Function(message)) - Telegram-style long-press context menu. Each item is{ label, icon?, onPress, destructive? }. See Message actions.renderCustomView(Component | Function) - Custom view inside the bubbleisCustomViewBottom(Bool) - Determine whether renderCustomView is displayed before or after the text, image and video views; default isfalseonPressMessage(Function(context,message)) - Callback when a message bubble is pressedonLongPressMessage(Function(context,message)) - Callback when a message bubble is long-pressed; you can use this to show action sheets (e.g., copy, delete, reply)isMessageGestureEnabled(Bool | Function(message)) - Whether the bubble itself is part of the row's tap / long-press surface thatreactionsandmessageActionsrely on; default istrue. Passfalse, or a predicate, for messages that render natively interactive content - the row beside the bubble stays pressable either way. See Interactive content inside bubbles.imageProps(Object) - Extra props to be passed to the<Image>component created by the defaultrenderMessageImageimageStyle(Object) - Custom style for message imagesvideoProps(Object) - Extra props to be passed to the video component created by the requiredrenderMessageVideomessageTextProps(Object) - Extra props to be passed to the MessageText component. Useful for customizing link parsing behavior, text styles, and matchers. Supports the following props:matchers- Custom matchers for linking message content (like URLs, phone numbers, hashtags, mentions)linkStyle- Custom style for linksemail- Enable/disable email parsing (default: true)phone- Enable/disable phone number parsing (default: true)url- Enable/disable URL parsing (default: true)hashtag- Enable/disable hashtag parsing (default: false)mention- Enable/disable mention parsing (default: false)hashtagUrl- Base URL for hashtags (e.g., 'https://x.com/hashtag')mentionUrl- Base URL for mentions (e.g., 'https://x.com')stripPrefix- Strip 'http://' or 'https://' from URL display (default: false)TextComponent- Custom Text component to use (e.g., from react-native-gesture-handler)
Example:
<Chat
messageTextProps={{
phone: false, // Disable default phone number linking
matchers: [
{
type: 'phone',
pattern: /\+?[1-9][0-9\-\(\) ]{7,}[0-9]/g,
getLinkUrl: (replacerArgs: ReplacerArgs): string => {
return replacerArgs[0].replace(/[\-\(\) ]/g, '')
},
getLinkText: (replacerArgs: ReplacerArgs): string => {
return replacerArgs[0]
},
style: styles.linkStyle,
onPress: (match: CustomMatch) => {
const url = match.getAnchorHref()
const options: {
title: string
action?: () => void
}[] = [
{ title: 'Copy', action: () => setStringAsync(url) },
{ title: 'Call', action: () => Linking.openURL(`tel:${url}`) },
{ title: 'Send SMS', action: () => Linking.openURL(`sms:${url}`) },
{ title: 'Cancel' },
]
showActionSheetWithOptions({
options: options.map(o => o.title),
cancelButtonIndex: options.length - 1,
}, (buttonIndex?: number) => {
if (buttonIndex === undefined)
return
const option = options[buttonIndex]
option.action?.()
})
},
},
],
linkStyle: { left: { color: 'blue' }, right: { color: 'lightblue' } },
}}
/>See full example in LinksExample
Avatars
renderAvatar(Component | Function) - Custom message avatar; set tonullto not render any avatar for the messageisUserAvatarVisible(Bool) - Whether to render an avatar for the current user; default isfalse, only show avatars for other usersisAvatarVisibleForEveryMessage(Bool) - When false, avatars will only be displayed when a consecutive message is from the same user on the same day; default isfalseonPressAvatar(Function(user)) - Callback when a message avatar is tappedonLongPressAvatar(Function(user)) - Callback when a message avatar is long-pressedisAvatarOnTop(Bool) - Render the message avatar at the top of consecutive messages, rather than the bottom; default isfalse
Username
isUsernameVisible(Bool) - Indicate whether to show the user's username inside the message bubble; default isfalserenderUsername(Component | Function) - Custom Username container
Date & Time
timeFormat(String) - Format to use for rendering times; default is'LT'(see Day.js Format)dateFormat(String) - Format to use for rendering dates; default is'D MMMM'(see Day.js Format)dateFormatCalendar(Object) - Format to use for rendering relative times; default is{ sameDay: '[Today]' }(see Day.js Calendar)renderDay(Component | Function) - Custom day above a message. This is also how the day label is styled - it receivesDayProps(createdAt,dateFormat,dateFormatCalendar,containerStyle,wrapperStyle,textProps,isAnimated), so render the built-inDaywith the styles you want:import { Chat, Day, DayProps } from '@kesha-antonov/react-native-chat' <Chat renderDay={(props: DayProps) => ( <Day {...props} wrapperStyle={{ backgroundColor: '#eee' }} textProps={{ style: { color: '#333' } }} /> )} />isAnimatedistruefor the floating header that sticks to the top while scrolling andfalsefor the inline separators, so one function can style them differently.renderTime(Component | Function) - Custom time inside a messagetimeTextStyle(Object) - Custom text style for time inside messages (supports left/right styles)isDayAnimationEnabled(Bool) - Enable animated day label that appears on scroll; default istrue
System Messages
renderSystemMessage(Component | Function) - Custom system message
Load Earlier Messages
loadEarlierMessagesProps(Object) - Props to pass to the LoadEarlierMessages component. The button is only visible whenisAvailableistrue. Supports the following props:isAvailable- Controls button visibility (default: false)onPress- Callback when button is pressedisLoading- Display loading indicator (default: false)isInfiniteScrollEnabled- Enable infinite scroll up when reaching the top of messages container, automatically callsonPress(not yet supported for web)label- Override the default "Load earlier messages" textcontainerStyle- Custom style for the button containerwrapperStyle- Custom style for the button wrappertextStyle- Custom style for the button textactivityIndicatorStyle- Custom style for the loading indicatoractivityIndicatorColor- Color of the loading indicator (default: 'white')activityIndicatorSize- Size of the loading indicator (default: 'small')
renderLoadEarlier(Component | Function) - Custom "Load earlier messages" button
Typing Indicator
isTyping(Bool) - Typing Indicator state; defaultfalse. If you userenderFooterit will override this.renderTypingIndicator(Component | Function) - Custom typing indicator componenttypingIndicatorStyle(StyleProp) - Custom style for the TypingIndicator component.renderFooter(Component | Function) - Custom footer component on the ListView, e.g.'User is typing...'; see CustomizedFeaturesExample.tsx for an example. Overrides default typing indicator that triggers whenisTypingis true.
Quick Replies
See Quick Replies example in messages.ts
onQuickReply(Function) - Callback when sending a quick reply (to backend server)renderQuickReplies(Function) - Custom all quick reply viewquickReplyStyle(StyleProp) - Custom quick reply view stylequickReplyTextStyle(StyleProp) - Custom text style for quick reply buttonsquickReplyContainerStyle(StyleProp) - Custom container style for quick repliesrenderQuickReplySend(Function) - Custom quick reply send view
Reply to Messages
React Native Chat supports swipe-to-reply functionality out of the box. When enabled, users can swipe on a message to reply to it, displaying a reply preview in the input toolbar and the replied message above the new message bubble.
Note: This feature uses
ReanimatedSwipeablefromreact-native-gesture-handlerandreact-native-reanimatedfor smooth, performant animations.
Basic Usage
<Chat
messages={messages}
onSend={onSend}
user={{ _id: 1 }}
reply={{
swipe: {
isEnabled: true,
direction: 'left', // swipe left to reply
},
}}
/>Reply Props (Grouped)
The reply prop accepts an object with the following structure:
interface ReplyProps<TMessage> {
// Swipe gesture configuration
swipe?: {
isEnabled?: boolean // Enable swipe-to-reply; default false
direction?: 'left' | 'right' // Swipe direction; default 'left'
onSwipe?: (message: TMessage) => void // Callback when swiped
renderAction?: ( // Custom swipe action component
progress: SharedValue<number>,
translation: SharedValue<number>,
position: 'left' | 'right'
) => React.ReactNode
actionContainerStyle?: StyleProp<ViewStyle>
}
// Reply preview styling (above input toolbar)
previewStyle?: {
containerStyle?: StyleProp<ViewStyle>
textStyle?: StyleProp<TextStyle>
imageStyle?: StyleProp<ImageStyle>
}
// In-bubble reply styling
messageStyle?: {
containerStyle?: StyleProp<ViewStyle>
containerStyleLeft?: StyleProp<ViewStyle>
containerStyleRight?: StyleProp<ViewStyle>
textStyle?: StyleProp<TextStyle>
textStyleLeft?: StyleProp<TextStyle>
textStyleRight?: StyleProp<TextStyle>
imageStyle?: StyleProp<ImageStyle>
}
// Callbacks and state
message?: ReplyMessage // Controlled reply state
onClear?: () => void // Called when reply cleared
onPress?: (message: TMessage) => void // Called when reply preview tapped
// Custom renderers
renderPreview?: (props: ReplyPreviewProps) => React.ReactNode
renderMessageReply?: (props: MessageReplyProps) => React.ReactNode
}ReplyMessage Structure
When a message has a reply, it includes a replyMessage property:
interface ReplyMessage {
_id: string | number
text: string
user: User
image?: string
audio?: string
}Advanced Example with External State
const [replyMessage, setReplyMessage] = useState<ReplyMessage | null>(null)
<Chat
messages={messages}
onSend={messages => {
const newMessages = messages.map(msg => ({
...msg,
replyMessage: replyMessage || undefined,
}))
setMessages(prev => Chat.append(prev, newMessages))
setReplyMessage(null)
}}
user={{ _id: 1 }}
reply={{
swipe: {
isEnabled: true,
direction: 'right',
onSwipe: setReplyMessage,
},
message: replyMessage,
onClear: () => setReplyMessage(null),
onPress: (msg) => scrollToMessage(msg._id),
}}
/>Smooth Animations
The reply preview automatically animates when:
- Appearing: Smoothly expands from zero height with fade-in effect
- Disappearing: Smoothly collapses with fade-out effect
- Content changes: Smoothly transitions when replying to a different message
These animations use react-native-reanimated for 60fps performance.
Scroll to Bottom
isScrollToBottomEnabled(Bool) - Enables the scroll to bottom Component (Default is false)scrollToBottomComponent(Function) - Custom Scroll To Bottom Component containerscrollToBottomOffset(Integer) - Custom Height Offset upon which to begin showing Scroll To Bottom Component (Default is 200)scrollToBottomStyle(Object) - Custom style for Scroll To Bottom wrapper (position, bottom, right, etc.)scrollToBottomContentStyle(Object) - Custom style for Scroll To Bottom content (size, background, shadow, etc.)
Maintaining Scroll Position (AI Chatbots)
For AI chat interfaces where long responses arrive and you don't want to disrupt the user's reading position, use maintainVisibleContentPosition via listProps:
// Basic usage - always maintain scroll position
<Chat
listProps={{
maintainVisibleContentPosition: {
minIndexForVisible: 0,
},
}}
/>
// With auto-scroll threshold - auto-scroll if within 10 pixels of newest content
<Chat
listProps={{
maintainVisibleContentPosition: {
minIndexForVisible: 0,
autoscrollToTopThreshold: 10,
},
}}
/>
// Conditionally enable based on scroll state (recommended for chatbots)
const [isScrolledUp, setIsScrolledUp] = useState(false)
<Chat
listProps={{
onScroll: (event) => {
setIsScrolledUp(event.contentOffset.y > 50)
},
maintainVisibleContentPosition: isScrolledUp
? { minIndexForVisible: 0, autoscrollToTopThreshold: 10 }
: undefined,
}}
/>Streaming (AI) Messages
Render AI assistant replies token-by-token. The library batches incoming chunks with requestAnimationFrame (one render per frame, only the streaming bubble re-renders) and shows a blinking caret while a message is still streaming.
The reply above streams in token-by-token (note the caret
▋) and renders as markdown - bold, italics, lists, inline and fenced code - while the composer's send button turns into a Stop control mid-stream. See Markdown rendering for AI replies to enable markdown.
IMessage.streaming- flag a message as streaming (shows the caret)useStreamingMessages(...)- owns the message list, rAF-batchespush(), and supports stop viaAbortController. It returns{ messages, setMessages, append, startStream, isStreaming, stop };setMessagesis there for anything the hook does not cover, so you can patch a message with a plainmap.
import { useCallback } from 'react'
import { Chat, IMessage, useStreamingMessages } from '@kesha-antonov/react-native-chat'
function Bot () {
const { messages, append, startStream, isStreaming, stop } = useStreamingMessages<IMessage>()
const onSend = useCallback((newMessages: IMessage[] = []) => {
append(newMessages[0]) // show the user's message
const stream = startStream({ user: { _id: 2, name: 'Assistant' } }) // empty streaming bubble
runMyModel(newMessages[0].text, {
signal: stream.signal, // aborts when stop() is called
onToken: token => stream.push(token), // batched, one render per frame
onDone: () => stream.done(), // clears the streaming flag
})
}, [append, startStream])
return <Chat messages={messages} onSend={onSend} user={{ _id: 1 }} />
}See docs/STREAMING.md for the full hook API and a real Claude streaming adapter (via a backend proxy). A runnable demo lives in example/components/chat-examples/AIBotExample.tsx.
Markdown rendering for AI replies
AI/LLM replies are usually markdown (bold, lists, fenced code). Enable markdown with messageTextProps={{ markdown: true }} (streamed messages auto-render as markdown unless you pass markdown={false}):
// Force markdown for every message:
<Chat messageTextProps={{ markdown: true }} {...props} />
// Streamed messages auto-render as markdown; disable with markdown: false.There are two renderers and you get the best available one automatically:
Built-in, zero-dependency renderer (default). Covers headings, bullet/ordered lists, blockquotes, fenced + inline code, bold/italic/strikethrough, and links. It handles streaming-incomplete markdown gracefully (a half-written
**boldor an unclosed code fence renders as plain text until complete), so it's safe to feed token-by-token. Nothing to install. Exposed asBasicMarkdownif you want to use it directly.react-native-streamdown(optional upgrade). When installed it's used instead, for richer streaming-safe markdown (tables, partial-table handling, etc.). It is a native module with its own peers - install the full set:npx expo install react-native-streamdown react-native-enriched-markdown remend katexIt also requires
react-native-worklets >= 0.8.3(i.e.react-native-reanimated >= 4.3), andreact-native-enriched-markdownis a native module, so a dev build / prebuild is required (it does not work in Expo Go). Pass through Streamdown's own theming/rules viamarkdownProps:<Chat messageTextProps={{ markdown: true, markdownProps: { /* ... */ } }} {...props} />
Emoji Reactions
Long-press a message to open a quick emoji picker; selected reactions render as pills below the bubble and toggle on tap. The core ships a lightweight quick picker (built on react-native-gesture-handler and react-native-reanimated, no extra dependencies). A full emoji browser is optional and demonstrated in the example app via the renderReactionPicker override.
Store reactions on each message as a reactions array, then enable the feature and handle the toggle. Reaction state is owned by you, so it works with any backend:
interface IChatMessage extends IMessage {
reactions?: MessageReaction[] // { emoji: string, userIds: (string | number)[] }[]
}
const CURRENT_USER_ID = 1
const handleReactionPress = useCallback((message: IChatMessage, emoji: string) => {
setMessages(prev =>
prev.map(m => {
if (m._id !== message._id)
return m
const existing = (m.reactions ?? []).find(r => r.emoji === emoji)
if (!existing)
return { ...m, reactions: [...(m.reactions ?? []), { emoji, userIds: [CURRENT_USER_ID] }] }
const userIds = existing.userIds.includes(CURRENT_USER_ID)
? existing.userIds.filter(id => id !== CURRENT_USER_ID)
: [...existing.userIds, CURRENT_USER_ID]
return {
...m,
reactions: userIds.length === 0
? (m.reactions ?? []).filter(r => r.emoji !== emoji)
: (m.reactions ?? []).map(r => (r.emoji === emoji ? { ...r, userIds } : r)),
}
})
)
}, [])
<Chat
messages={messages}
onSend={onSend}
user={{ _id: CURRENT_USER_ID }}
reactions={{
isEnabled: true,
onReactionPress: handleReactionPress,
// Optional: provide a richer picker (e.g. a full emoji browser).
// See example/components/chat-examples/ReactionsExample.tsx
// renderReactionPicker: props => <MyEmojiPicker {...props} />,
}}
/>Reactions Props (Grouped)
The reactions prop accepts:
isEnabled(Bool) - Enable emoji reactions (defaultfalse)emojis(String[]) - Emojis shown in the quick picker (default['👍', '❤️', '😂', '😮', '😢', '👎'])onReactionPress(Function) -(message, emoji) => voidcalled when an emoji is selected or a pill is tapped. Toggle logic is left to yourenderReactions(Function) - Override the reactions-display component rendered below the bubblerenderReactionPicker(Function) - Override the picker shown on long-press (use for a full emoji browser)containerStyle,reactionStyle,reactionActiveStyle,reactionTextStyle,reactionCountStyle- Styles for the reaction pillspickerContainerStyle,pickerEmojiStyle- Styles for the quick picker
Smart Link Parsing
Message text is automatically scanned for URLs, emails, and phone numbers; hashtags and mentions are opt-in. Configure it via messageTextProps:
<Chat
messageTextProps={{
url: true, // default true
email: true, // default true
phone: true, // default true
hashtag: true, // default false
mention: true, // default false
hashtagUrl: 'https://example.com/hashtag',
mentionUrl: 'https://example.com',
linkStyle: { left: { color: '#1d9bf0' }, right: { color: '#fff' } },
onPress: (message, url, type) => {
// type: 'url' | 'email' | 'phone' | 'mention' | 'hashtag'
Linking.openURL(url)
},
}}
/>For full control, pass custom matchers ({ type, pattern, getLinkUrl?, getLinkText?, renderLink?, onPress? }[]) to add or override patterns. See the Links example in the example app.
Message actions (long-press context menu)
Long-press a message to open a floating, themed context menu (Telegram style) anchored to the bubble. Provide the actions via messageActions - an array, or a function of the message - each { label, icon?, onPress, destructive? }. When reactions are enabled, a reactions row is shown on top of the menu automatically.
import { setStringAsync } from 'expo-clipboard'
import { Copy, Trash2 } from 'lucide-react-native' // optional icons
<Chat
messageActions={message => [
{ label: 'Copy', icon: ({ color, size }) => <Copy color={color} size={size} />, onPress: () => setStringAsync(message.text) },
{ label: 'Delete', destructive: true, onPress: () => deleteMessage(message) },
]}
/>Interactive content inside bubbles (video players, maps)
When reactions or messageActions are enabled, the long-press surface spans the whole message row - the bubble and the empty space beside it, the way Telegram behaves on Android. (A tap gesture is added on top only when onPressMessage is set.) Those recognizers do not cancel touches on native subviews, so a react-native-video / expo-video player rendered through renderMessageVideo keeps its native controls interactive.
If a message must own every touch that lands on it, set isMessageGestureEnabled to false for it. The gesture surface then drops behind the bubble: the bubble's content takes its touches, and long-pressing the row next to the bubble still opens the picker - so reactions are never lost for that message.
<Chat
reactions={{ isEnabled: true, onReactionPress }}
renderMessageVideo={props => <Video source={{ uri: props.currentMessage.video }} controls style={styles.video} />}
// the video owns its controls; long-press beside the bubble still reacts
isMessageGestureEnabled={message => !message.video}
/>Note: This library no longer depends on
@expo/react-native-action-sheet. PrefermessageActionsabove. If you specifically want a native action sheet, install it yourself, wrap your tree inActionSheetProvider, and either calluseActionSheet()in your ownonLongPressMessageor pass anactionSheetprop - theactionSheetprop /context.actionSheet()escape hatch still works when you provide an implementation. The composer "+" actions use the built-in themedAttachmentSheetand need no setup.
Theming & Dark Mode
The chat ships with a modern default look and a full token-based theme. Override any subset of tokens via theme (light) and darkTheme (dark); your overrides are deep-merged over defaultLightTheme / defaultDarkTheme, and the resolved theme switches at runtime with the color scheme (system, or forced via colorScheme). Explicit per-component style props still win over the theme.
<Chat
theme={{
colors: { accent: '#3390EC', outgoingBubble: '#EFFEDE' },
radii: { bubble: 18 },
}}
darkTheme={{ colors: { background: '#0E1621', incomingBubble: '#182533' } }}
// colorScheme="dark" // optional: force a scheme instead of following the system
{...props}
/>Token groups: colors, radii, spacing, typography, avatar, sendButton, composer, voice. Build your own theme-aware components with the exported hooks:
import { useTheme, useThemedStyles } from '@kesha-antonov/react-native-chat'
import { StyleSheet } from 'react-native'
const MyBadge = () => {
const theme = useTheme()
const styles = useThemedStyles(t => StyleSheet.create({
badge: { backgroundColor: t.colors.accent, borderRadius: t.radii.bubble },
}))
return <View style={styles.badge} />
}Also exported: defaultLightTheme, defaultDarkTheme, and the ChatTheme / PartialChatTheme types.
Localization (i18n)
All built-in UI strings (composer placeholder, send/cancel, load earlier, today, voice/video/location labels, slide-to-cancel, reply/edit banner, camera-permission text) route through a label table. Built-in translations ship for en, es, fr, de, and ru, selected by the existing locale prop. Override any individual string with labels:
<Chat
locale="fr" // pick a built-in translation
labels={{ placeholder: 'Votre message...' }} // override any string
{...props}
/>Exported helpers: ChatLabels (type), defaultLabels, translations, resolveLabels, and the useLabels hook for reading the resolved labels in custom components.
Message Status
Set sent, received, or pending on a message to show its delivery status. By default these render as tick indicators next to the timestamp (✓ sent, ✓✓ received, 🕓 pending):
const message: IMessage = {
_id: 1,
text: 'Delivered!',
createdAt: new Date(),
user: { _id: 1 },
sent: true,
received: true,
}Customize the indicators with renderTicks (full override) or tickStyle (style only):
<Chat
renderTicks={message => (message.received ? <MyReadIcon /> : null)}
tickStyle={{ color: '#1d9bf0' }}
/>TypeScript
Chat ships complete type definitions and is generic over your message type. Extend IMessage to add custom fields and everything stays typed end to end:
import { Chat, IMessage } from '@kesha-antonov/react-native-chat'
interface MyMessage extends IMessage {
reactions?: { emoji: string, userIds: (string | number)[] }[]
}
<Chat<MyMessage>
messages={messages}
onSend={msgs => {/* msgs is typed as MyMessage[] */}}
user={{ _id: 1 }}
/>📱 Platform Notes
Android
If you are using Create React Native App / Expo, no Android specific installation steps are required. Otherwise, we recommend modifying your project configuration:
Make sure you have android:windowSoftInputMode="adjustResize" in your AndroidManifest.xml:
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize">For Expo, you can append KeyboardAvoidingView after Chat (Android only):
<View style={{ flex: 1 }}>
<Chat />
{Platform.OS === 'android' && <KeyboardAvoidingView behavior="padding" />}
</View>Web (react-native-web)
- Install react-app-rewired:
yarn add -D react-app-rewired - Create
config-overrides.js:
module.exports = function override(config, env) {
config.module.rules.push({
test: /\.js$/,
exclude: /node_modules[/\\](?!react-native-chat)/,
use: {
loader: 'babel-loader',
options: {
babelrc: false,
configFile: false,
presets: [
['@babel/preset-env', { useBuiltIns: 'usage' }],
'@babel/preset-react',
],
plugins: ['@babel/plugin-proposal-class-properties'],
},
},
})
return config
}⚡ Performance
The chat is built for long lists, but a few habits on your side unlock the most:
Memoize your render props and config. Each message row is wrapped in React.memo with a comparator that deep-compares the message and reference-compares every other prop. So an unchanged row only skips a re-render when the props you pass it are referentially stable. If you pass inline functions or objects, that row re-renders on every parent render:
// ❌ New reference every render - the row can't skip
<Chat renderBubble={props => <MyBubble {...props} />} reactions={{ isEnabled: true, onReactionPress }} />
// ✅ Stable references - unchanged rows skip re-renders
const renderBubble = useCallback(props => <MyBubble {...props} />, [])
const reactions = useMemo(() => ({ isEnabled: true, onReactionPress }), [onReactionPress])
const messageActions = useCallback(message => [{ label: 'Copy', onPress: () => copy(message.text) }], [copy])
<Chat renderBubble={renderBubble} reactions={reactions} messageActions={messageActions} />This applies to all render props (renderBubble, renderMessageText, renderAvatar, ...), the reactions / audioRecording / videoRecording / messageActions config objects, and any style objects.
Keep messages immutable. Update messages by creating new arrays/objects (e.g. Chat.append(...)), never by mutating an existing message in place - the row comparator relies on value changes to detect updates.
Theme, icons and labels don't need drilling. theme / darkTheme, icons, and labels are read from context (useTheme, useIcons, useLabels), so passing them once on <Chat> is enough; they don't cause per-row churn.
Tune virtualization if needed. Sensible FlatList defaults ship out of the box (removeClippedSubviews on Android, initialNumToRender, maxToRenderPerBatch, windowSize, updateCellsBatchingPeriod). windowSize is measured in screen-heights (not messages); the default keeps a few screens of content mounted around the viewport. Override any of them via listProps:
<Chat listProps={{ windowSize: 7, removeClippedSubviews: true }} {...props} />FlashList (opt-in)
On long histories FlatList can log VirtualizedList: You have a large list that is slow to update. FlashList v2 recycles rows instead of keeping them mounted, which removes that class of stall. It is supported as an optional dependency - install it yourself and flip one prop:
yarn add @shopify/flash-list<Chat messages={messages} user={user} isFlashListEnabled />Everything else keeps working: isInverted, the floating day header, loadEarlierMessagesProps infinite scroll, the scroll-to-bottom button, and listProps (spread last, so it overrides the defaults below).
The chat sets FlashList's maintainVisibleContentPosition for you - startRenderingFromBottom when isInverted={false}, plus autoscrollToBottomThreshold: 0.2 so new messages follow the viewport only when you are already at the bottom. Override it through listProps if you want different thresholds:
<Chat
isFlashListEnabled
listProps={{
maintainVisibleContentPosition: {
autoscrollToBottomThreshold: 0.1,
animateAutoScrollToBottom: false,
},
}}
{...props}
/>Notes:
- FlashList v2 requires the New Architecture. On the old architecture it falls back to a slower JS path.
FlatList-only knobs (windowSize,maxToRenderPerBatch,initialNumToRender,updateCellsBatchingPeriod,removeClippedSubviews) are not forwarded to FlashList - it sizes its own render window.- If
@shopify/flash-listis not installed, the prop is ignored, a warning is logged, andFlatListis used.
🧪 Testing
TEST_ID is exported as constants that can be used in your testing library of choice.
React Native Chat uses onLayout to determine the height of the chat container. To trigger onLayout during your tests:
const WIDTH = 200
const HEIGHT = 2000
const loadingWrapper = getByTestId(TEST_ID.LOADING_WRAPPER)
fireEvent(loadingWrapper, 'layout', {
nativeEvent: {
layout: {
width: WIDTH,
height: HEIGHT,
},
},
})📦 Example App
The repository includes a comprehensive example app demonstrating all features:
# Clone and install
git clone https://github.com/kesha-antonov/react-native-chat.git
cd react-native-chat/example
yarn install
# Run on iOS
npx expo run:ios
# Run on Android
npx expo run:android
# Run on Web
npx expo start --webThe example app showcases:
- 💬 Basic chat functionality
- 🎨 Custom message bubbles and avatars
- ↩️ Reply to messages with swipe gesture
- ⚡ Quick replies (bot-style)
- ✍️ Typing indicators
- 📎 Attachment actions
- 🔗 Link parsing and custom matchers
- 🌐 Web compatibility
❓ Troubleshooting
Make sure you have android:windowSoftInputMode="adjustResize" in your AndroidManifest.xml. See Android configuration above.
See this issue for examples.
See this issue for examples.
See this issue for examples.
See this issue for examples.
🤔 Have a Question?
- Check this README first
- Search existing issues
- Ask on StackOverflow
- Open a new issue if needed
🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Install dependencies (
yarn install) - Make your changes
- Run tests (
yarn test) - Run linting (
yarn lint) - Build the library (
yarn build) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Development Setup
# Install dependencies
yarn install
# Build the library
yarn build
# Run tests
yarn test
# Run linting
yarn lint
# Full validation
yarn prepublishOnly👥 Authors
Based on FaridSafi/react-native-gifted-chat, which is no longer actively maintained.
Maintainer: Kesha Antonov
I maintained the original project solo for 2 years before deciding to continue development in this repository. If you find this library useful, please consider becoming a sponsor to support continued development. 💖
