Chat bubble
Free · MITReact Native · ExpoMessage bubbles that group into runs with one tail, carry a receipt that ticks forward (clock, a check that draws itself, two checks, both brightening when read) and spring up out of the composer; plus a TypingIndicator whose dots wave and a Composer whose mic morphs into a filled send arrow as soon as there is text.
Installation
$ npx shadcn@latest add https://goodui.dev/native/r/chat-bubble.jsonThen run npx expo install --fix so react-native-reanimated, react-native-svg match your Expo SDK. Files land in components/goodui-native/chat-bubble.
Usage
import { useState } from "react";
import { View } from "react-native";
import { ChatBubble, Composer, TypingIndicator } from "@/components/goodui-native/chat-bubble/chat-bubble";
export function Thread({ messages, typing, onSend }) {
const [draft, setDraft] = useState("");
return (
<View style={{ gap: 2, padding: 16 }}>
{messages.map((m, i) => (
<ChatBubble
key={m.id}
side={m.mine ? "sent" : "received"}
position={m.position}
time={m.time}
status={m.mine ? m.status : undefined}
animateIn={m.isNew}
onRetry={() => resend(m.id)}
>
{m.text}
</ChatBubble>
))}
<TypingIndicator visible={typing} name="Maya" />
<Composer value={draft} onChangeText={setDraft} onSend={onSend} onMicPress={startRecording} />
</View>
);
}When to use
- Conversations: support chat, marketplace messages, team and group chats, AI assistants.
- Showing delivery state of something the person sent (sending, sent, delivered, read, failed with retry).
- A message input row with attach, a growing field and a send or voice button.
- Comments under a post with avatars and replies: use a list-row layout; bubbles suggest a private conversation.
- A one-off confirmation of an action: use toast or the button's own success state.
- A form field: use text-field.
Examples
Computing positions for a run
function positionOf(list: Msg[], i: number): ChatPosition {
const same = (a?: Msg, b?: Msg) => !!a && !!b && a.side === b.side && a.sender === b.sender;
const prev = same(list[i - 1], list[i]);
const next = same(list[i + 1], list[i]);
return prev && next ? "middle" : prev ? "last" : next ? "first" : "single";
}Reactions on a bubble (uses reaction-picker)
<ChatBubble
side="received"
reaction={m.reaction}
reactionCount={m.reactionCount}
onReactionChange={(key) => react(m.id, key)}
>
{m.text}
</ChatBubble>Group chat
<ChatBubble sender="Aiko" position="first" time="07:58">Who is in for Saturday?</ChatBubble>API reference
ChatBubble
| Prop | Type | Default |
|---|---|---|
| children* The message text. | string | — |
| side `sent` sits on the trailing side in the accent; `received` on the leading side in a quiet fill. | "sent" | "received" | "received" |
| position Position in a run from the same sender. Only `single` and `last` carry a tail. Default `single`. | "single" | "first" | "middle" | "last" | "single" |
| time Short time inside the bubble, for example "9:41". | string | — |
| status Delivery state of a sent message. The receipt ticks forward as it changes: a clock while sending, one check that draws itself in once sent, a second for delivered, and both brighten and pop when read. | "sent" | "sending" | "delivered" | "read" | "failed" | — |
| onRetry Shown under a failed message as "Not delivered, tap to retry". | () => void | — |
| sender Sender name above the first bubble of a received run (group chats) and in the screen reader label. | string | — |
| animateIn Springs up out of the composer (sent) or in from the leading edge (received) when it mounts. Use it for messages that arrive while the screen is open, not for history. | boolean | false |
| reaction Your reaction on this message (a reaction key), `null` for none. Passing this or `onReactionChange` makes the bubble a ReactionPicker: long-press for the pill, and the pick lands in a badge on the bubble's edge. | string | null | — |
| onReactionChange Called with the picked reaction key, or `null` when it is removed. | (key: string | null) => void | — |
| reactions The reactions in the pill. Defaults to ReactionPicker's set. | Reaction[] | — |
| reactionCount Everyone's reactions on the message, shown in the badge when above 1. | number | — |
| labels Overrides for the built-in strings; a GooduiLocaleProvider pack sets them app-wide. | Partial<ChatBubbleLabels> | — |
| style Style for the outer container. | StyleProp<ViewStyle> | — |
| testID Identifier for end-to-end tests. | string | — |
TypingIndicator
| Prop | Type | Default |
|---|---|---|
| visible Shows the bubble. Turning it off shrinks it back into its tail before it unmounts. Default `true`. | boolean | true |
| name Who is typing, for screen readers: "Maya is typing". | string | — |
| labels Overrides for the built-in strings; a GooduiLocaleProvider pack sets them app-wide. | Partial<TypingIndicatorLabels> | — |
| style Style for the outer container. | StyleProp<ViewStyle> | — |
| testID Identifier for end-to-end tests. | string | — |
Composer
| Prop | Type | Default |
|---|---|---|
| value Text (controlled). Pair it with `onChangeText`. | string | — |
| defaultValue Initial text when uncontrolled. | string | "" |
| onChangeText Called on every change, and with "" after a send. | (text: string) => void | — |
| onSend* Called with the trimmed text when the send button is pressed (or Enter on the web). The field clears. | (text: string) => void | — |
| onMicPress Called when the button is pressed while the field is empty. With it, the empty button is a microphone that morphs into the send arrow as soon as there is text. Without it, the empty button is a dimmed send arrow. | () => void | — |
| placeholder Placeholder. Same as `labels.placeholder`. | string | — |
| leading Controls before the field, for example an attach IconButton. | ReactNode | — |
| maxRows Rows the field grows to before it scrolls. Default 5. | number | 5 |
| maxLength Maximum characters. | number | — |
| disabled Dims the control and ignores presses. | boolean | false |
| haptic `false` silences the send haptic. | boolean | true |
| labels Overrides for the built-in strings (the button's two names, the placeholder); a GooduiLocaleProvider pack sets them app-wide. | Partial<ComposerLabels> | — |
| inputRef Ref to the TextInput, for example to focus it after choosing a reply. | Ref<ComponentRef<typeof TextInput>> | — |
| accessibilityLabel Screen reader label of the field. Default the placeholder. | string | — |
| style Style for the outer container. | StyleProp<ViewStyle> | — |
| testID Identifier for end-to-end tests. | string | — |
Motion and haptics
- animateIn: a sent bubble springs up 24 pt from 0.6 scale out of its bottom trailing corner on the bouncy spring; a received one comes in from the leading edge.
- Receipt: the first check draws itself in when sent, the second when delivered; when read both brighten and pop once. Statuses a bubble already had when it mounted render settled.
- TypingIndicator grows out of its tail on the bouncy spring and shrinks back before unmounting; its three dots rise and brighten in a staggered wave every 1.2 s.
- Composer: the accent fill grows out of the button's center while the mic rotates away and the arrow rotates in (morph spring); sending launches the arrow up 22 pt as it fades. The field grows line by line on the snappy spring.
- Reduced motion: bubbles and the typing bubble fade in, checks appear without drawing, there is no read pop, the typing dots only pulse their opacity, the composer button crossfades and the field resizes instantly.
- Haptic: send on send, tap on the mic and on retry (Composer haptic={false} turns its haptics off)
- Every animation runs on the UI thread. The typing wave is one repeating shared value read by three dots.
- The composer measures its text with a hidden mirror only when the text changes, and resizes with a spring instead of per-frame state.
- Bubbles are plain views: render long threads in a FlatList (inverted for chat) and pass animateIn only to new messages.
Accessibility
- On iOS and Android each bubble is one element read as 'You: text, 9:41, read' or 'Maya: text, 9:41'. On the web the text names it, with 'You:' and the status as visually hidden text.
- A failed message shows 'Not delivered, tap to retry' as a real button under the bubble.
- TypingIndicator is a polite live region: 'Maya is typing' (role status with visually hidden text on the web).
- Composer: the field is labelled by its placeholder (or accessibilityLabel); the button's label follows its state ('Record voice message' or 'Send') and it reports disabled when there is nothing to send. On the web Enter sends and Shift+Enter adds a line.
- With reactions, the bubble carries the reaction picker's actions ('React with Love') and its label adds 'you reacted with Love' (visually hidden text after the message on the web).
- Message text uses the 17 pt body size and scales with the system text size; the bubble grows with it.
Notes for AI
- children is the message string. side is 'sent' or 'received'; position is 'single' | 'first' | 'middle' | 'last' and you compute it from neighbours (see the example).
- status only shows on sent bubbles. Move it forward as the server reports: sending, sent, delivered, read; 'failed' shows the retry row (pass onRetry).
- Pass animateIn only for messages that arrive while the thread is open, never for history.
- Use a small gap (2 pt) between bubbles of a run and a larger one (8 pt) between runs.
- Composer clears itself after onSend; keep value and onChangeText if you control the draft. onMicPress makes the empty button a microphone; without it the empty button is a dimmed send arrow.
- Put the Composer at the bottom of the screen above the keyboard and the safe area; it does not add insets itself.
- Reactions: pass reaction and onReactionChange instead of wrapping the bubble in ReactionPicker yourself.
Related
Long-press a message and a pill of emoji springs out of it one by one; slide the finger across and the emoji under it swells dock-style while its neighbours lean in; let go to pick and the emoji flies in an arc onto the message, landing in a badge with a pop.
A photo that fades in over initials on a color picked from the name, with a presence dot and a story ring whose accent arc sweeps in when there is something new and away once seen; AvatarGroup stacks people with a +N counter and fans out on tap.
A text field whose label glides up as you focus it, with helper and error text that slide in, a shake and haptic on error, clear and reveal buttons, a character counter and a multiline mode that grows with its text.
Long-press an element and it lifts out of the page above a dimmed backdrop while a menu springs out beside it; keep the finger down, slide onto an item and release to choose. Right-click opens it on the web.