Chat screen

ProReact Native · Expo

A complete one-to-one chat thread: a header with the contact's avatar, presence and call buttons, messages grouped by day and by sender, receipts that tick forward, typing dots, long-press reactions and a composer that rides the keyboard; a sent message springs up out of the composer and ticks from clock to read.

View as Markdown
Try it on your phone
Scan to open this demo in your phone's browser and use it with your fingers.

Installation

$ npx shadcn@latest add @goodui-native-pro/chat-screen

Then run npx expo install --fix so react-native-reanimated, react-native-svg match your Expo SDK. Files land in components/goodui-native/chat-screen.

Pro screens install from the license-gated @goodui-native-pro registry. Setup: Pro access.

  • Wrap the app in GestureHandlerRootView and ThemeProvider.

Usage

// app/chat/[id].tsx
import { useState } from "react";
import { Stack, router, useLocalSearchParams } from "expo-router";
import { ChatScreen, type ChatMessage } from "@/components/goodui-native/chat-screen/chat-screen";
import { api, useThread } from "@/lib/chat";

export default function ChatRoute() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const { contact, messages, typing } = useThread(id); // your realtime source, oldest first
  return (
    <>
      <Stack.Screen options={{ headerShown: false }} />
      <ChatScreen
        contact={contact}
        messages={messages}
        typing={typing}
        onSend={(text) => api.send(id, text)} // append it to messages with status "sending", then move it forward
        onRetry={(m) => api.resend(id, m.id)}
        onReactionChange={(messageId, reaction) => api.react(id, messageId, reaction)}
        onBack={() => router.back()}
        onCall={() => router.push(`/call/${id}`)}
        onContactPress={() => router.push(`/profile/${contact.id}`)}
      />
    </>
  );
}

When to use

Use it for
  • The thread of a messaging, dating, marketplace or support app: one person at the other end.
  • Any conversation where delivery state matters to the sender (orders, bookings, payments between people).
Reach for something else
  • Group chats with several senders: compose ChatBubble with its sender prop yourself; this screen labels runs by side only.
  • Thousands of messages in memory: the thread is a ScrollView; page older history or move the bubbles into an inverted FlatList.
  • An AI assistant with streaming answers and markdown: bubbles hold plain text.

Examples

Uncontrolled: the screen keeps the thread and drives the receipts

<ChatScreen
  contact={{ name: "Leo Martins", online: true }}
  defaultMessages={history}
  onSend={(text) => api.send(text)} // pending: clock; resolved: sent, then delivered; rejected: retry row
  onBack={() => router.back()}
/>

On its own, with a sample thread

<ChatScreen />

API reference

PropTypeDefault
contact
The person at the other end.
ChatContactsampleContact
messages
The thread (controlled), oldest first. With it, `onSend` only reports the text and you append the message and move its status forward yourself.
ChatMessage[]—
defaultMessages
The starting thread when uncontrolled. The screen then appends what you send and drives the receipts from `onSend`: sending while it is pending, sent then delivered when it resolves, failed with a retry when it rejects.
ChatMessage[]sampleMessages
onSend
Called with the trimmed text when the person sends. Uncontrolled, return a promise to drive the receipts.
(text: string) => unknown—
onRetry
Retry was tapped under a failed message. Uncontrolled, the screen resends through `onSend` when omitted.
(message: ChatMessage) => unknown—
onReactionChange
Your reaction on a message changed (a key, or null when removed).
(id: string, reaction: string | null) => void—
typing
The contact is typing: dots in the thread and "Typing…" under the name.
booleanfalse
onBack
Shows a back button.
() => void—
onCall
Shows a call button.
() => void—
onVideoCall
Shows a video call button.
() => void—
onContactPress
Tapping the name or avatar, for example to open the profile.
() => void—
onAttach
Shows an attach button before the field.
() => void—
onMicPress
With it, the empty send button is a microphone.
() => void—
placeholder
Composer placeholder. Default "Message". Same as `labels.placeholder`.
string—
locale
BCP 47 locale for times and day labels. Defaults to the device locale.
string—
labels
Overrides for the built-in strings; a GooduiLocaleProvider pack sets them app-wide.
Partial<ChatScreenLabels>—
testID
Identifier for end-to-end tests.
string—

Motion and haptics

  • A sent message springs up out of the composer; incoming messages arrive from the leading edge. History on screen at mount never animates.
  • Receipts tick forward in the bubble: a clock while sending, one check drawing in once sent, a second for delivered, both brightening when read.
  • The thread follows new messages with an animated scroll only when you are already near the bottom, so reading history is never interrupted.
  • The composer rides the keyboard with the keyboard's own duration (native); the web page resizes itself.
  • Reduced motion: bubbles fade in place, the thread jumps to the newest message instead of scrolling.
  • Haptic: tap on send, on the mic and on retry (Composer, ChatBubble)
  • Haptic: tap when the reaction pill opens, select for each emoji the finger slides onto and on the pick (ReactionPicker)
  • Haptic: tap on the back, call, video and attach buttons
  • Bubbles, receipts and the keyboard offset animate on the UI thread; React renders when messages, the draft or typing change.
  • The thread is a ScrollView: comfortable up to a few hundred messages; page history beyond that.

Accessibility

  • Day labels are headers; each bubble is one element read as 'You: text, 9:41, read' or 'Message: text, 9:41' for received ones, adding 'you reacted with Love' when you reacted.
  • Reactions are also in each bubble's screen reader actions ('React with Love'), so nobody has to long-press. A failed message has a real 'Not delivered, tap to retry' button.
  • The header names the contact with their presence ('Maya Patel, Online' or 'Typing…'); with onContactPress it is a button.
  • Back, call, video and attach are icon buttons with labels; the typing dots announce '<name> is typing'.
  • On the web the thread is a log region named 'Conversation with <name>'.

Notes for AI

  • Controlled: pass messages (oldest first, time as Date) and append the sent message yourself in onSend with status 'sending', then update status as your server reports. Uncontrolled: pass defaultMessages and return a promise from onSend.
  • Message ids must be stable: ids that were not on screen at mount are treated as new and spring in.
  • Buttons appear only for the callbacks you pass: onBack, onCall, onVideoCall, onAttach, onMicPress, onContactPress.
  • Drive typing from your realtime events; it shows the dots and 'Typing…' under the name.
  • Render it full-bleed with the navigator header hidden (headerShown: false); it handles the top and bottom safe areas and the keyboard itself, so do not wrap it in KeyboardAvoidingView.