Chat bubble

Free · MITReact Native · Expo

Message 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.

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 https://goodui.dev/native/r/chat-bubble.json

Then 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

Use it for
  • 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.
Reach for something else
  • 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

PropTypeDefault
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.
booleanfalse
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

PropTypeDefault
visible
Shows the bubble. Turning it off shrinks it back into its tail before it unmounts. Default `true`.
booleantrue
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

PropTypeDefault
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.
number5
maxLength
Maximum characters.
number—
disabled
Dims the control and ignores presses.
booleanfalse
haptic
`false` silences the send haptic.
booleantrue
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.