Message
React NativeRows that rise out of their corner, with avatars and typing dots.
Preview


Install
Source on GitHubIt works in Expo and in bare React Native. New to beUI Native? Set up your app first.
Copies the component and the shared files it needs into src/, adds the theme tokens if they're missing, and installs its libraries. Files you've changed are kept.
Keep the folder layout, since the component imports the shared files by relative path.
1Install the libraries
npx expo install react-native-gesture-handler react-native-reanimated react-native-svg react-native-worklets
npm install uniwind tailwindcssWrap your app in GestureHandlerRootView if it isn't already.
2Add the theme tokens
Once per app. Import it from the CSS file Uniwind reads, after Tailwind and Uniwind, and change the values to match your brand.
/* Matches the default beUI palette in ui-components/lib/themes.ts.
Native surface maps to card; muted maps to muted-foreground. */
@layer theme {
:root {
@variant light {
--color-background: oklch(99% 0 0);
--color-surface: oklch(97% 0 0);
--color-foreground: oklch(15% 0 0);
--color-muted: oklch(50% 0 0);
--color-border: oklch(15% 0 0 / 0.06);
--color-border-strong: oklch(15% 0 0 / 0.16);
--color-primary: #0285f7;
--color-on-primary: #ffffff;
--color-destructive: #dc2626;
--color-success: #059669;
--color-warning: #d97706;
}
}
:root {
@variant dark {
--color-background: #151515;
--color-surface: #1c1c1c;
--color-foreground: oklch(96% 0 0);
--color-muted: oklch(62% 0 0);
--color-border: rgb(255 255 255 / 0.05);
--color-border-strong: rgb(255 255 255 / 0.14);
--color-primary: #0285f7;
--color-on-primary: #ffffff;
--color-destructive: #f87171;
--color-success: #34d399;
--color-warning: #fbbf24;
}
}
/* Tailwind's default monospace stack isn't a font React Native can load. */
:root {
@variant ios {
--font-mono: Menlo;
}
}
:root {
@variant android {
--font-mono: monospace;
}
}
}
3Copy the shared files
import { useEffect, useRef, useState } from "react";
export type PresenceItem<T> = {
key: string;
value: T;
/** False for the item present on first render, so it does not animate in. */
appear: boolean;
exiting: boolean;
};
/**
* Keeps replaced items mounted while they animate out, like AnimatePresence.
* A key that returns while exiting is revived in place.
*/
export function usePresence<T>(
key: string | null,
value: T,
exitDuration: (value: T) => number,
) {
const [items, setItems] = useState<PresenceItem<T>[]>(() =>
key === null ? [] : [{ key, value, appear: false, exiting: false }],
);
const [currentKey, setCurrentKey] = useState(key);
const exitDurationRef = useRef(exitDuration);
exitDurationRef.current = exitDuration;
// Adjust during render so the new item and the exits start in the same frame.
if (key !== currentKey) {
setCurrentKey(key);
setItems((previous) => {
const next = previous.map((item) =>
item.key === key
? { ...item, value, exiting: false }
: { ...item, exiting: true },
);
if (key !== null && !previous.some((item) => item.key === key)) {
next.push({ key, value, appear: true, exiting: false });
}
return next;
});
}
const exitingKeys = items
.filter((item) => item.exiting)
.map((item) => item.key)
.join("\n");
useEffect(() => {
if (!exitingKeys) return;
const timers = items
.filter((item) => item.exiting)
.map((item) =>
setTimeout(() => {
setItems((previous) =>
previous.filter(
(candidate) => !(candidate.key === item.key && candidate.exiting),
),
);
}, exitDurationRef.current(item.value)),
);
return () => {
for (const timer of timers) clearTimeout(timer);
};
// Restart removal timers only when the set of exiting items changes.
}, [exitingKeys]);
return items;
}
import { useEffect, useState } from "react";
import { AccessibilityInfo } from "react-native";
import { useReducedMotion } from "react-native-reanimated";
/** Reanimated supplies a synchronous initial value; native events keep it current. */
export function useReducedMotionPreference() {
const initialValue = useReducedMotion();
const [reducedMotion, setReducedMotion] = useState(initialValue);
useEffect(() => {
let active = true;
let receivedEvent = false;
const subscription = AccessibilityInfo.addEventListener(
"reduceMotionChanged",
(value) => {
receivedEvent = true;
setReducedMotion(value);
},
);
void AccessibilityInfo.isReduceMotionEnabled().then((value) => {
if (active && !receivedEvent) setReducedMotion(value);
});
return () => {
active = false;
subscription.remove();
};
}, []);
return reducedMotion;
}
import type { ReactNode } from "react";
import type { ColorValue } from "react-native";
import Svg, { Circle, Path, Rect } from "react-native-svg";
type GlyphProps = { size?: number; color?: ColorValue; strokeWidth?: number };
function Glyph({
size = 16,
color = "currentColor",
strokeWidth = 2,
children,
}: GlyphProps & { children: ReactNode }) {
return (
<Svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</Svg>
);
}
// Simple 24-unit stroke glyphs shared by the agent components.
export function ChevronDownGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M6 9l6 6 6-6" />
</Glyph>
);
}
export function ArrowUpGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M12 19V5M5.5 11.5L12 5l6.5 6.5" />
</Glyph>
);
}
export function PlusGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M12 5v14M5 12h14" />
</Glyph>
);
}
export function StopGlyph({ size = 12, color }: GlyphProps) {
return (
<Svg width={size} height={size} viewBox="0 0 12 12">
<Rect x={1} y={1} width={10} height={10} rx={2.5} fill={color} />
</Svg>
);
}
export function CheckGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M5 12.5l4.5 4.5L19 7" />
</Glyph>
);
}
export function CopyGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Rect x={8.5} y={8.5} width={12} height={12} rx={2.5} />
<Path d="M15.5 5.5v-.5A2 2 0 0 0 13.5 3h-8.5A2 2 0 0 0 3 5v8.5a2 2 0 0 0 2 2h.5" />
</Glyph>
);
}
export function RetryGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1L3.5 8.5" />
<Path d="M3.5 3.5v5h5" />
</Glyph>
);
}
export function ThumbGlyph({
down = false,
...props
}: GlyphProps & { down?: boolean }) {
return (
<Glyph {...props}>
<Path
d="M7.5 10.5v10M7.5 10.5L11 3.5a2.5 2.5 0 0 1 2.5 2.5v3.5h5a2 2 0 0 1 2 2.3l-1.2 7A2 2 0 0 1 17.3 20.5H4.5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1z"
transform={down ? "rotate(180 12 12)" : undefined}
/>
</Glyph>
);
}
export function SearchGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Circle cx={11} cy={11} r={6.5} />
<Path d="M16 16l4 4" />
</Glyph>
);
}
export function GlobeGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Circle cx={12} cy={12} r={8.5} />
<Path d="M3.5 12h17M12 3.5c2.4 2.5 3.5 5.3 3.5 8.5s-1.1 6-3.5 8.5c-2.4-2.5-3.5-5.3-3.5-8.5s1.1-6 3.5-8.5z" />
</Glyph>
);
}
export function FileGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M14 3.5H7.5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V8z" />
<Path d="M14 3.5V8h4.5M9 13h6M9 16.5h4" />
</Glyph>
);
}
export function PencilGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M4 20h4L19 9a2.1 2.1 0 0 0-4-4L4 16z" />
<Path d="M13.5 6.5l4 4M14 20h6" />
</Glyph>
);
}
export function TerminalGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Rect x={3.5} y={4.5} width={17} height={15} rx={2.5} />
<Path d="M7.5 10l2.5 2-2.5 2M12.5 15h4" />
</Glyph>
);
}
export function WrenchGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M14.5 6.5a4 4 0 0 0 5 5l-8.8 8.8a2.1 2.1 0 0 1-3-3l8.8-8.8a4 4 0 0 1-2-2z" />
</Glyph>
);
}
export function SparkleGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M12 4l1.8 4.6L18.5 10.5l-4.7 1.9L12 17l-1.8-4.6-4.7-1.9 4.7-1.9z" />
<Path d="M18.5 16.5v3M17 18h3" />
</Glyph>
);
}
export function MessageGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M20 14.5a2 2 0 0 1-2 2H8.5L4 20V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z" />
</Glyph>
);
}
export function ImageGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Rect x={3.5} y={4.5} width={17} height={15} rx={2.5} />
<Circle cx={9} cy={10} r={1.5} />
<Path d="M20.5 15.5l-4.5-4.5-9 8.5" />
</Glyph>
);
}
export function ShieldGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M12 3.5l7 2.5v5.5c0 4.4-3 7.7-7 9-4-1.3-7-4.6-7-9V6z" />
<Path d="M9 12l2 2 4-4" />
</Glyph>
);
}
export function AlertGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Circle cx={12} cy={12} r={8.5} />
<Path d="M12 8v4.5M12 16h.01" />
</Glyph>
);
}
export function CrossGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M6.5 6.5l11 11M17.5 6.5l-11 11" />
</Glyph>
);
}
export function HelpGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Circle cx={12} cy={12} r={8.5} />
<Path d="M9.6 9.5a2.5 2.5 0 0 1 4.8 1c0 1.7-2.4 2.2-2.4 3.5M12 16.8h.01" />
</Glyph>
);
}
export function ArrowGlyph({
back = false,
...props
}: GlyphProps & { back?: boolean }) {
return (
<Glyph {...props}>
<Path
d="M5 12h14M13 6l6 6-6 6"
transform={back ? "rotate(180 12 12)" : undefined}
/>
</Glyph>
);
}
export function BracesGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M8.5 4H8a2 2 0 0 0-2 2v3.5A2.5 2.5 0 0 1 3.5 12 2.5 2.5 0 0 1 6 14.5V18a2 2 0 0 0 2 2h.5M15.5 4h.5a2 2 0 0 1 2 2v3.5a2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0-2.5 2.5V18a2 2 0 0 1-2 2h-.5" />
</Glyph>
);
}
export function BanGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Circle cx={12} cy={12} r={8.5} />
<Path d="M6 6l12 12" />
</Glyph>
);
}
export function CircleCheckGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Circle cx={12} cy={12} r={8.5} />
<Path d="M8.5 12.2l2.4 2.4 4.6-4.8" />
</Glyph>
);
}
export function CircleCrossGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Circle cx={12} cy={12} r={8.5} />
<Path d="M9.5 9.5l5 5M14.5 9.5l-5 5" />
</Glyph>
);
}
export function ListGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M4 6.5l1.5 1.5L8 5.5M4 13l1.5 1.5L8 12M11.5 7h8.5M11.5 13.5h8.5M11.5 19h8.5M4.5 19h2" />
</Glyph>
);
}
/** An open ring, spun by SpinGlyph. */
export function RingGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M20.5 12a8.5 8.5 0 1 1-6-8.1" />
</Glyph>
);
}
export function ReplyGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M9.5 7L4.5 12l5 5M4.5 12h10a5 5 0 0 1 5 5v1" />
</Glyph>
);
}
export function WrapGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M4 6.5h16M4 12h12.5a3.25 3.25 0 0 1 0 6.5H12M4 18.5h4.5M14 16.5l-2 2 2 2" />
</Glyph>
);
}
export function BookGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M12 6.5c-1.6-1.3-4-2-7.5-2v13c3.5 0 5.9.7 7.5 2 1.6-1.3 4-2 7.5-2v-13c-3.5 0-5.9.7-7.5 2zM12 6.5v13" />
</Glyph>
);
}
export function ExternalGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M14 4.5h5.5V10M19.5 4.5L11 13M17 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h4" />
</Glyph>
);
}
export function DoubleCheckGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Path d="M2.5 12.5l4.5 4.5 9-9.5M12 16.5l.5.5 9-9.5" />
</Glyph>
);
}
export function ClockGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Circle cx={12} cy={12} r={8.5} />
<Path d="M12 7.5V12l3 2" />
</Glyph>
);
}
export function MicGlyph(props: GlyphProps) {
return (
<Glyph {...props}>
<Rect x={9} y={3.5} width={6} height={11} rx={3} />
<Path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5v3" />
</Glyph>
);
}
import {
type ColorValue,
StyleSheet,
View,
type ViewStyle,
} from "react-native";
const BANDS = 8;
export type FadeEdgeProps = {
/** The surface color the content fades into. */
color: ColorValue | undefined;
/** Which edge is solid. Defaults to "bottom". */
edge?: "top" | "bottom";
/** Height of the fade in points. */
height: number;
style?: ViewStyle;
};
/**
* A soft edge over clipped content, built from stacked bands so shared
* components need no gradient library. Ignores touches.
*/
export function FadeEdge({
color,
edge = "bottom",
height,
style,
}: FadeEdgeProps) {
return (
<View
pointerEvents="none"
style={[
styles.edge,
edge === "bottom" ? styles.bottom : styles.top,
{ height },
style,
]}
>
{Array.from({ length: BANDS }, (_, index) => {
const t = (index + 1) / BANDS;
return (
<View
key={index}
style={{
flex: 1,
backgroundColor: color,
// Ease the ramp so the fade has no visible first step.
opacity: edge === "bottom" ? t * t : (1 - t + 1 / BANDS) ** 2,
}}
/>
);
})}
</View>
);
}
const styles = StyleSheet.create({
edge: { position: "absolute", left: 0, right: 0 },
bottom: { bottom: 0 },
top: { top: 0 },
});
import { useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
import { usePresence } from "../hooks/use-presence";
import { EASE_OUT, SPRING_SWAP } from "../styles/motion";
const EXIT = { duration: 140, easing: EASE_OUT };
const TRAVEL = 0.9;
/** Progress runs 0 (below) → 1 (resting) → 2 (rolled away above). */
function Layer({
text,
appear,
exiting,
reduce,
height,
className,
lines,
}: {
text: string;
appear: boolean;
exiting: boolean;
reduce: boolean;
height: number;
className: string;
lines?: number;
}) {
const progress = useSharedValue(appear && !reduce ? 0 : 1);
useEffect(() => {
if (reduce) {
progress.value = exiting ? 2 : 1;
return;
}
progress.value = exiting ? withTiming(2, EXIT) : withSpring(1, SPRING_SWAP);
}, [exiting, progress, reduce]);
const style = useAnimatedStyle(() => {
const p = progress.value;
return {
opacity: Math.max(0, Math.min(1, p <= 1 ? p : 2 - p)),
transform: [{ translateY: (1 - p) * height * TRAVEL }],
};
}, [height]);
return (
<Animated.View style={[styles.layer, style]}>
<Text numberOfLines={lines} className={className}>
{text}
</Text>
</Animated.View>
);
}
export type RollTextProps = {
/** Changing the value rolls the new text in; defaults to the text itself. */
value?: string;
text: string;
className: string;
reduce: boolean;
/** Lines before truncating. Defaults to 1; pass 0 to wrap freely. */
numberOfLines?: number;
};
/**
* Text that rolls up when it changes: the new line rises in from below as the
* old one rolls up and away. Clipped to its own lines, so only the text moves.
*/
export function RollText({
value,
text,
className,
reduce,
numberOfLines = 1,
}: RollTextProps) {
const lines = numberOfLines === 0 ? undefined : numberOfLines;
const [height, setHeight] = useState(16);
const items = usePresence(value ?? text, text, () =>
reduce ? 0 : EXIT.duration + 40,
);
return (
<View style={styles.slot}>
{/* Sizes the slot to the current text. */}
<Text
numberOfLines={lines}
className={className}
style={styles.sizer}
onLayout={({ nativeEvent }) => setHeight(nativeEvent.layout.height)}
>
{text}
</Text>
{items.map((item) => (
<Layer
key={item.key}
text={item.value}
appear={item.appear}
exiting={item.exiting}
reduce={reduce}
height={height}
className={className}
lines={lines}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
slot: { overflow: "hidden", flexShrink: 1, minWidth: 0 },
sizer: { opacity: 0 },
layer: { position: "absolute", left: 0, right: 0, top: 0 },
});
import { Easing, ReduceMotion } from "react-native-reanimated";
// Mirrors beUI's lib/ease.ts so native motion matches the web library.
export const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);
export const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
stiffness: 500,
damping: 30,
mass: 0.6,
reduceMotion: ReduceMotion.System,
} as const;
/** Content swaps: label and icon slots trading places inside a control. */
export const SPRING_SWAP = {
stiffness: 460,
damping: 30,
mass: 0.55,
reduceMotion: ReduceMotion.System,
} as const;
/** Shared-layout glides: pills, indicators and panels moving between positions. */
export const SPRING_LAYOUT = {
stiffness: 360,
damping: 32,
mass: 0.6,
reduceMotion: ReduceMotion.System,
} as const;
/** Dragged handles and fills; critically damped so values never rebound off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
reduceMotion: ReduceMotion.System,
} as const;
export const motion = {
press: SPRING_PRESS,
swap: SPRING_SWAP,
layout: SPRING_LAYOUT,
glide: SPRING_GLIDE,
exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },
fade: { duration: 150, reduceMotion: ReduceMotion.System },
} as const;
4Copy the component
export type {
MessageAvatarProps,
MessageDelivery,
MessageFrom,
MessageGroupProps,
MessageProps,
MessageStatusProps,
MessageTypingProps,
} from "./message";
export {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
MessageMarker,
MessageStatus,
MessageTyping,
} from "./message";
import { type ReactNode, type Ref, useContext, useEffect } from "react";
import { StyleSheet, Text, View, type ViewProps } from "react-native";
import Animated, {
cancelAnimation,
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSpring,
withTiming,
} from "react-native-reanimated";
import { useResolveClassNames } from "uniwind";
import { usePresence } from "../../hooks/use-presence";
import { useReducedMotionPreference } from "../../hooks/use-reduced-motion";
import {
AlertGlyph,
CheckGlyph,
ClockGlyph,
DoubleCheckGlyph,
} from "../../primitives/agent-icons";
import { RollText } from "../../primitives/roll-text";
import { EASE_OUT, SPRING_SWAP } from "../../styles/motion";
import { MessageSideContext } from "../message-bubble/message-bubble";
export type MessageFrom = "user" | "assistant";
// The row rises out of its trailing corner as it's sent.
const MESSAGE_POP_UP = {
stiffness: 480,
damping: 32,
mass: 0.62,
reduceMotion: ReduceMotion.System,
};
const RISE = 8;
export type MessageProps = Omit<ViewProps, "children"> & {
from: MessageFrom;
/** Pops the row up from its trailing corner once, on mount. Defaults to false. */
animateIn?: boolean;
/** Appears without motion, independently of the system preference. */
disableAnimation?: boolean;
className?: string;
children: ReactNode;
ref?: Ref<View>;
};
/**
* One message row: an optional avatar beside MessageContent. User rows sit on
* the trailing side, and bubbles inside follow the row's side.
*/
export function Message({
from,
animateIn = false,
disableAnimation = false,
className = "",
style,
children,
ref,
...props
}: MessageProps) {
const reducedMotion = useReducedMotionPreference();
const pop = animateIn && !reducedMotion && !disableAnimation;
const progress = useSharedValue(pop ? 0 : 1);
const user = from === "user";
// Plays once, on mount.
useEffect(() => {
if (pop) progress.value = withSpring(1, MESSAGE_POP_UP);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const p = progress.value;
return {
opacity: Math.min(1, p),
transform: [{ translateY: (1 - p) * RISE }, { scale: 0.95 + p * 0.05 }],
};
});
return (
<MessageSideContext.Provider value={user ? "end" : "start"}>
<Animated.View
ref={ref}
style={[
styles.row,
user && styles.reverse,
{ transformOrigin: [user ? "100%" : "0%", "100%", 0] },
animatedStyle,
style,
]}
className={className}
{...props}
>
{children}
</Animated.View>
</MessageSideContext.Provider>
);
}
export type MessageGroupProps = ViewProps & {
/** Space between rows: 6 (compact) or 16 points. Defaults to "compact". */
spacing?: "compact" | "default";
className?: string;
};
/** Stacks message rows. */
export function MessageGroup({
spacing = "compact",
className = "",
...props
}: MessageGroupProps) {
return (
<View
className={`${spacing === "compact" ? "gap-1.5" : "gap-4"} ${className}`}
{...props}
/>
);
}
export type MessageAvatarProps = ViewProps & {
/** Keeps the slot empty so grouped rows stay aligned. */
placeholder?: boolean;
className?: string;
/** Initials, an image or an icon. Strings are styled for you. */
children?: ReactNode;
};
/** A 28 point round avatar. */
export function MessageAvatar({
placeholder = false,
className = "",
children,
...props
}: MessageAvatarProps) {
return (
<View
importantForAccessibility={placeholder ? "no-hide-descendants" : "auto"}
accessibilityElementsHidden={placeholder}
style={[styles.avatar, placeholder && styles.hidden]}
className={`items-center justify-center overflow-hidden bg-surface ${className}`}
{...props}
>
{placeholder ? null : typeof children === "string" ? (
<Text className="text-xs font-medium text-muted">{children}</Text>
) : (
children
)}
</View>
);
}
/** The column beside the avatar, holding header, bubbles and footer. */
export function MessageContent({
className = "",
...props
}: ViewProps & { className?: string }) {
const side = useContext(MessageSideContext);
return (
<View
style={[
styles.content,
{ alignItems: side === "end" ? "flex-end" : "flex-start" },
]}
className={`gap-1.5 ${className}`}
{...props}
/>
);
}
function metaRow(minHeight: boolean) {
return function MetaRow({
className = "",
children,
...props
}: ViewProps & { className?: string }) {
return (
<View
style={minHeight && styles.footer}
className={`flex-row items-center gap-1.5 px-1 ${className}`}
{...props}
>
{typeof children === "string" ? (
<Text className="text-[11px] text-muted">{children}</Text>
) : (
children
)}
</View>
);
};
}
/** A small line above the bubbles: a name, a time. */
export const MessageHeader = metaRow(false);
/** A small line below the bubbles: delivery state, actions. */
export const MessageFooter = metaRow(true);
export type MessageDelivery =
| "sending"
| "sent"
| "delivered"
| "read"
| "failed";
const DELIVERY_LABEL: Record<MessageDelivery, string> = {
sending: "Sending",
sent: "Sent",
delivered: "Delivered",
read: "Read",
failed: "Not delivered",
};
/** The delivery icon, swapping with a quick scale as the state moves on. */
function DeliveryIcon({
delivery,
appear,
exiting,
reduce,
}: {
delivery: MessageDelivery;
appear: boolean;
exiting: boolean;
reduce: boolean;
}) {
const { color: muted } = useResolveClassNames("text-muted");
const { color: primary } = useResolveClassNames("text-primary");
const { color: destructive } = useResolveClassNames("text-destructive");
const shown = useSharedValue(appear && !reduce ? 0 : 1);
useEffect(() => {
shown.value = reduce
? exiting
? 0
: 1
: withSpring(exiting ? 0 : 1, SPRING_SWAP);
}, [exiting, reduce, shown]);
const style = useAnimatedStyle(() => ({
opacity: shown.value,
transform: [{ scale: 0.6 + shown.value * 0.4 }],
}));
const props = { size: 13, strokeWidth: 2 };
return (
<Animated.View style={[styles.deliveryIcon, style]}>
{delivery === "sending" ? (
<ClockGlyph {...props} color={muted} />
) : delivery === "sent" ? (
<CheckGlyph {...props} color={muted} />
) : delivery === "failed" ? (
<AlertGlyph {...props} color={destructive} />
) : (
<DoubleCheckGlyph
{...props}
color={delivery === "read" ? primary : muted}
/>
)}
</Animated.View>
);
}
export type MessageStatusProps = ViewProps & {
/** When the message was sent, already formatted, such as "9:41". */
time?: string;
/** Where the message is. The icon and label roll as it changes. */
delivery?: MessageDelivery;
/** Shows the delivery word beside its icon. Defaults to true. */
showLabel?: boolean;
disableAnimation?: boolean;
};
/**
* The time and delivery state under a message, such as "9:41 · Delivered"
* with two checks. As the state moves on, the label rolls and the icon swaps.
*/
export function MessageStatus({
time,
delivery,
showLabel = true,
disableAnimation = false,
...props
}: MessageStatusProps) {
const reducedMotion = useReducedMotionPreference();
const reduce = reducedMotion || disableAnimation;
const icons = usePresence(
delivery ?? null,
delivery as MessageDelivery,
() => (reduce ? 0 : 220),
);
const spoken = [time, delivery ? DELIVERY_LABEL[delivery] : undefined]
.filter(Boolean)
.join(", ");
return (
<MessageFooter
accessible
accessibilityLabel={spoken}
accessibilityLiveRegion="polite"
{...props}
>
{time ? (
<Text className="text-[11px] text-muted" style={styles.tabular}>
{time}
</Text>
) : null}
{time && delivery && showLabel ? (
<Text className="text-[11px] text-muted">·</Text>
) : null}
{delivery && showLabel ? (
<RollText
text={DELIVERY_LABEL[delivery]}
className={`text-[11px] ${delivery === "failed" ? "text-destructive" : "text-muted"}`}
reduce={reduce}
/>
) : null}
{delivery ? (
<View style={styles.deliverySlot}>
{icons.map((item) => (
<DeliveryIcon
key={item.key}
delivery={item.value}
appear={item.appear}
exiting={item.exiting}
reduce={reduce}
/>
))}
</View>
) : null}
</MessageFooter>
);
}
/** A centered pill between messages, for dates and system notes. */
export function MessageMarker({
className = "",
children,
...props
}: ViewProps & { className?: string }) {
return (
<View
className={`max-w-[88%] self-center rounded-full bg-surface px-2.5 py-1 ${className}`}
{...props}
>
{typeof children === "string" ? (
<Text className="text-xs text-muted">{children}</Text>
) : (
children
)}
</View>
);
}
const DOT_CYCLE = 1050;
const DOT_STAGGER = 140;
function TypingDot({
index,
color,
reduce,
}: {
index: number;
color: string;
reduce: boolean;
}) {
const pulse = useSharedValue(0);
useEffect(() => {
if (reduce) {
cancelAnimation(pulse);
pulse.value = 0;
return;
}
pulse.value = withDelay(
index * DOT_STAGGER,
withRepeat(
withTiming(1, { duration: DOT_CYCLE / 2, easing: EASE_OUT }),
-1,
true,
),
);
return () => cancelAnimation(pulse);
}, [index, pulse, reduce]);
const style = useAnimatedStyle(() =>
reduce
? { opacity: 0.45 }
: {
opacity: 0.28 + pulse.value * 0.57,
transform: [{ translateY: -2 * pulse.value }],
},
);
return (
<Animated.View style={[styles.dot, { backgroundColor: color }, style]} />
);
}
export type MessageTypingProps = ViewProps & {
/** Spoken in place of the dots. Defaults to "Responding". */
label?: string;
/** Color utility for the dots. Defaults to `text-muted`. */
className?: string;
disableAnimation?: boolean;
};
/** Three dots that rise and fade in turn while a reply is on its way. */
export function MessageTyping({
label = "Responding",
className = "text-muted",
disableAnimation = false,
...props
}: MessageTypingProps) {
const reducedMotion = useReducedMotionPreference();
const { color } = useResolveClassNames(className);
const tint = String(color ?? "gray");
return (
<View
accessible
accessibilityLabel={label}
accessibilityRole="progressbar"
style={styles.typing}
{...props}
>
{[0, 1, 2].map((index) => (
<TypingDot
key={index}
index={index}
color={tint}
reduce={reducedMotion || disableAnimation}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "flex-start",
gap: 8,
width: "100%",
},
reverse: { flexDirection: "row-reverse" },
avatar: { width: 28, height: 28, borderRadius: 14 },
hidden: { opacity: 0 },
content: { flex: 1, minWidth: 0 },
footer: { minHeight: 20 },
typing: { flexDirection: "row", alignItems: "center", gap: 4, height: 20 },
dot: { width: 4, height: 4, borderRadius: 2 },
deliverySlot: { width: 14, height: 14 },
deliveryIcon: { position: "absolute", left: 0, top: 0 },
tabular: { fontVariant: ["tabular-nums"] },
});
Documentation
Native port of beUI Message: the row around chat bubbles, with avatars, metadata and a typing indicator.
import {
Message,
MessageAvatar,
MessageBubble,
MessageContent,
MessageGroup,
MessageHeader,
MessageStatus,
MessageTyping,
} from "./src";
<MessageGroup spacing="default">
<Message from="user" animateIn>
<MessageAvatar>Y</MessageAvatar>
<MessageContent>
<MessageHeader>You</MessageHeader>
<MessageBubble variant="solid">Can you summarize the notes?</MessageBubble>
<MessageStatus time="9:41" delivery="delivered" />
</MessageContent>
</Message>
<Message from="assistant">
<MessageAvatar>AI</MessageAvatar>
<MessageContent>
<MessageBubble>
<MessageTyping />
</MessageBubble>
</MessageContent>
</Message>
</MessageGroup>Parts
Messagesets the side: user rows sit on the trailing side with the avatar after the content, and bubbles inside follow.MessageAvataris a 28 point circle for initials, an image or an icon.placeholderkeeps the slot empty so grouped replies stay aligned.MessageHeaderandMessageFooterare small muted lines above and below the bubbles.MessageStatussits under the bubbles with the time and delivery state, such as "9:41 · Delivered".deliveryissending(a clock),sent(one check),delivered(two checks),read(two blue checks) orfailed. The label rolls and the icon swaps as it changes. Leave outdeliveryfor just the time, or hide the word withshowLabel={false}.MessageMarkeris a centered pill for dates and system notes.MessageTypingshows three dots that rise and fade in turn.MessageGroupstacks rows 6 points apart, or 16 withspacing="default".
Motion
animateIn rises the row 8 points out of its trailing bottom corner as it scales from 0.95, on a spring (stiffness 480, damping 32, mass 0.62), once on mount. Use it for messages the person sends. The typing dots each rise 2 points and brighten over a 1050 ms cycle, 140 ms apart.
Accessibility
- The typing indicator is a progress bar named "Responding". Change it with
label. - Placeholder avatars are hidden from screen readers.
- Reduced motion, or
disableAnimation, shows rows without the rise and holds the dots still.
Copy this directory and components/message-bubble/ with its files, hooks/use-reduced-motion.ts, styles/motion.ts, and the semantic tokens in styles/theme.css.