Chat message surface with variants (solid, soft, tint, outline, ghost, danger), alignment, entrance animation, and optional timestamp. Sent bubbles pop in with a restrained overshoot.
Live preview — the real component, driven by the props below.
import { MessageBubble } from "@/components/ui/message-bubble/message-bubble";
export function Example() {
return (
<MessageBubble />
);
} import * as React from "react";
import { forwardRef, useMemo } from "react";
import { View, Text, Pressable, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
interpolate,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { cn, useReducedMotion, spring, timing, useHaptic, usePressAnimation } from "@lumioui/core";
import { Icon } from "@lumioui/ui";
/**
* MessageBubble — chat message surface with variants, alignment, entrance
* animation, swipe-to-reply, and long-press context actions.
* <MessageBubble align="start" variant="soft">Hello!</MessageBubble>
* <MessageBubble align="end" variant="solid" animateIn
* onReply={() => setReplyTo(msg)} onLongPress={() => showActions(msg)}>
* On my way
* </MessageBubble>
* Swipe right on a bubble to trigger onReply. Long-press for onLongPress.
*/
export type MessageBubbleVariant = "solid" | "soft" | "tint" | "outline" | "ghost" | "danger";
export type MessageBubbleAlign = "start" | "end";
export interface MessageBubbleProps extends ViewProps {
/** Bubble style. Default "soft". */
variant?: MessageBubbleVariant;
/** Which side. Default "start". */
align?: MessageBubbleAlign;
/** Play an entrance animation when mounted. Default false. */
animateIn?: boolean;
/** Optional timestamp shown below the bubble (exposed to screen readers). */
timestamp?: string;
/** Optional sender name — exposed to screen readers, not rendered. */
sender?: string;
/** Press handler — bubbles can be tappable (e.g. to open actions). */
onPress?: () => void;
/** Swipe-right callback — shows a reply icon during the gesture. */
onReply?: () => void;
/** Long-press callback — for context menus / action sheets. */
onLongPress?: () => void;
/** Previous bubble in the group shares this align (same sender run) —
* tightens the corner on the grouped side. Auto-inferred inside
* MessageBubbleGroup; set explicitly to override. */
sameAsPrev?: boolean;
/** Next bubble in the group shares this align — keeps the grouped corner
* tight through the whole run. */
sameAsNext?: boolean;
/** Enable swipe-to-reply gesture. Default true when onReply is set. */
enableSwipeReply?: boolean;
className?: string;
textClassName?: string;
children?: React.ReactNode;
}
const surfaceClass: Record<MessageBubbleVariant, string> = {
solid: "bg-foreground",
soft: "bg-muted",
tint: "bg-primary/10",
outline: "border border-border bg-card",
ghost: "",
danger: "bg-destructive/10",
};
const textClass: Record<MessageBubbleVariant, string> = {
solid: "text-background",
soft: "text-foreground",
tint: "text-foreground",
outline: "text-card-foreground",
ghost: "text-foreground",
danger: "text-destructive",
};
// No tail nub — the corner on the aligned side goes square instead.
const alignClass: Record<MessageBubbleAlign, string> = {
start: "self-start rounded-bl-none",
end: "self-end rounded-br-none",
};
/** Reply drag: 50pt commits (past the icon), clamped at 80pt so the bubble never chases the finger. */
const REPLY_THRESHOLD = 50;
const REPLY_MAX = 80;
export const MessageBubble = forwardRef<React.ElementRef<typeof View>, MessageBubbleProps>(
function MessageBubble(
{ variant = "soft", align = "start", animateIn = false, timestamp, sender, onPress, onReply, onLongPress, enableSwipeReply, sameAsPrev = false, sameAsNext = false, className, textClassName, children, accessibilityLabel, ...props },
ref
) {
const reduce = useReducedMotion();
const haptics = useHaptic();
const canSwipe = enableSwipeReply ?? !!onReply;
const scale = useSharedValue(animateIn && !reduce ? 0.92 : 1);
const opacity = useSharedValue(animateIn ? 0 : 1);
const translateY = useSharedValue(animateIn && !reduce ? 8 : 0);
const dragX = useSharedValue(0);
const replyIconOpacity = useSharedValue(0);
const longPressScale = useSharedValue(1);
const ctxStartX = useSharedValue(0);
// Ticks once per drag when the reply threshold is crossed — the
// "release now to reply" beat, reset when the drag drops back under.
const crossed = useSharedValue(false);
const press = usePressAnimation({ pressedScale: 0.97 });
React.useEffect(() => {
if (!animateIn) return;
if (reduce) {
opacity.value = withTiming(1, timing.normal);
return;
}
scale.value = withSpring(1, spring.pop);
opacity.value = withTiming(1, timing.normal);
translateY.value = withSpring(0, spring.layout);
}, [animateIn, reduce, scale, opacity, translateY]);
// Swipe-to-reply gesture
const pan = useMemo(() => {
if (!canSwipe || reduce) return null;
return Gesture.Pan()
.activeOffsetX(10)
.onStart(() => {
"worklet";
ctxStartX.value = dragX.value;
})
.onUpdate((e) => {
"worklet";
// Only allow rightward drag, clamped
const next = Math.max(0, Math.min(ctxStartX.value + e.translationX, REPLY_MAX));
dragX.value = next;
replyIconOpacity.value = interpolate(next, [0, REPLY_THRESHOLD], [0, 1], "clamp");
if (next > REPLY_THRESHOLD && !crossed.value) {
crossed.value = true;
runOnJS(haptics.selection)();
} else if (next <= REPLY_THRESHOLD && crossed.value) {
crossed.value = false;
}
})
.onEnd((e) => {
"worklet";
crossed.value = false;
if (dragX.value > REPLY_THRESHOLD || e.velocityX > 500) {
runOnJS(haptics.selection)();
runOnJS(onReply ?? (() => {}))();
}
dragX.value = withSpring(0, spring.press);
// Icon recedes faster than the entrance fade so dismissal feels snappy.
replyIconOpacity.value = withTiming(0, timing.fast);
});
}, [canSwipe, reduce, ctxStartX, dragX, replyIconOpacity, crossed, haptics, onReply]);
// Long-press gesture — squash while held, then release through
// spring.bouncy so the bubble pops back with a whisper of overshoot.
const longPress = useMemo(() => {
if (!onLongPress) return null;
return Gesture.LongPress()
.minDuration(400)
.onStart(() => {
"worklet";
longPressScale.value = withSpring(0.95, spring.press);
runOnJS(haptics.medium)();
runOnJS(onLongPress)();
})
.onEnd(() => {
"worklet";
longPressScale.value = withSpring(1, spring.bouncy);
});
}, [onLongPress, haptics, longPressScale]);
// Latest-press trampoline — keeps the tap gesture stable across renders.
const onPressRef = React.useRef(onPress);
onPressRef.current = onPress;
const firePress = React.useCallback(() => onPressRef.current?.(), []);
// Tap gesture — only built when other gestures already own the touch
// surface (swipe-reply / long-press); the plain onPress-only case keeps
// its Pressable wrapper below. Races the long-press: a quick release
// taps, a held finger squashes into the context action instead.
const tap = useMemo(() => {
if (!onPress || (!pan && !longPress)) return null;
return Gesture.Tap()
.maxDistance(10)
.onBegin(() => {
"worklet";
if (!reduce) press.scale.value = withTiming(0.97, timing.fast);
})
.onStart(() => {
"worklet";
runOnJS(firePress)();
})
.onFinalize(() => {
"worklet";
if (!reduce) press.scale.value = withSpring(1, spring.press);
});
}, [onPress, pan, longPress, reduce, press.scale, firePress]);
const style = useAnimatedStyle(() => ({
transform: [
{ scale: scale.value * longPressScale.value * press.scale.value },
{ translateX: dragX.value },
{ translateY: translateY.value },
],
opacity: opacity.value,
}));
const replyIconStyle = useAnimatedStyle(() => ({
opacity: replyIconOpacity.value,
transform: [
// Parallax: the icon trails the bubble at 60% so it reads as
// anchored to the thread edge, not glued to the bubble.
{ translateX: dragX.value * 0.6 - 4 },
{ scale: interpolate(replyIconOpacity.value, [0, 1], [0.5, 1]) },
],
}));
// One label, one interactive role — whoever wraps the bubble (the
// gesture detector's host view or the Pressable) carries "button"; the
// bubble surface itself always stays "text".
const a11yLabel =
accessibilityLabel ??
([sender, typeof children === "string" ? children : null, timestamp]
.filter(Boolean)
.join(", ") || undefined);
// iMessage-style grouping: a same-sender run tightens the corner toward
// the previous bubble; the aligned side's corner stays square.
const content = (
<Animated.View
ref={ref}
style={style}
className={cn(
"max-w-[82%] rounded-2xl px-3.5 py-2.5",
alignClass[align],
sameAsPrev && (align === "end" ? "rounded-tr-md" : "rounded-tl-md"),
variant !== "ghost" && surfaceClass[variant],
className
)}
accessibilityRole="text"
accessibilityLabel={a11yLabel}
{...props}
>
<Text className={cn("text-sm leading-5", textClass[variant], textClassName)}>
{children}
</Text>
{timestamp ? (
<Text className={cn("mt-1 text-[10px]", textClass[variant], "opacity-60")}>
{timestamp}
</Text>
) : null}
</Animated.View>
);
// Compose gestures: the pan (swipe-reply) runs simultaneously with a
// race between long-press and tap — a held finger squashes, a quick
// release taps.
const discrete = [longPress, tap].filter(
(g): g is NonNullable<typeof g> => g !== null
);
const pressLane =
discrete.length > 1
? Gesture.Race(discrete[0]!, discrete[1]!)
: discrete[0] ?? null;
const gestures = [pressLane, pan].filter(
(g): g is NonNullable<typeof g> => g !== null
);
const composed = gestures.length > 1 ? Gesture.Simultaneous(...gestures) : gestures[0] ?? null;
if (composed) {
return (
// Owns its width so self-start/self-end bubbles align to the thread edges.
<View className={cn("w-full flex-row items-center", align === "end" ? "justify-end" : "justify-start")}>
{canSwipe && (
<Animated.View style={[{ position: "absolute", left: -28 }, replyIconStyle]} pointerEvents="none">
<Icon name="arrow-left" size="sm" color="primary" />
</Animated.View>
)}
<GestureDetector gesture={composed}>
<View
collapsable={false}
accessible={onPress ? true : undefined}
accessibilityRole={onPress ? "button" : undefined}
accessibilityLabel={onPress ? a11yLabel : undefined}
>
{content}
</View>
</GestureDetector>
</View>
);
}
if (onPress) {
return (
<Pressable
onPress={onPress}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
accessibilityRole="button"
accessibilityLabel={a11yLabel}
>
{content}
</Pressable>
);
}
return content;
}
);
MessageBubble.displayName = "MessageBubble";
export interface MessageBubbleGroupProps extends ViewProps {
/** Spacing between bubbles. Default "compact". */
spacing?: "compact" | "default";
className?: string;
children?: React.ReactNode;
}
type BubbleElement = React.ReactElement<MessageBubbleProps>;
function isBubble(child: React.ReactNode): child is BubbleElement {
return React.isValidElement(child) && child.type === MessageBubble;
}
/**
* MessageBubbleGroup — stacks bubbles and infers same-sender runs: adjacent
* MessageBubble children with the same `align` get `sameAsPrev`/`sameAsNext`
* injected (explicit props win), which tightens the grouped corners and
* leaves the tail nub on the last bubble of each run.
*/
export const MessageBubbleGroup = forwardRef<React.ElementRef<typeof View>, MessageBubbleGroupProps>(
function MessageBubbleGroup({ spacing = "compact", className, children, ...props }, ref) {
const kids = React.Children.toArray(children);
return (
<View
ref={ref}
className={cn("w-full", spacing === "compact" ? "gap-1.5" : "gap-3", className)}
{...props}
>
{kids.map((child, i) => {
if (!isBubble(child)) return child;
const p = child.props;
const align = p.align ?? "start";
const prev = kids[i - 1];
const next = kids[i + 1];
const inferredPrev =
isBubble(prev) && (prev.props.align ?? "start") === align;
const inferredNext =
isBubble(next) && (next.props.align ?? "start") === align;
return React.cloneElement(child, {
sameAsPrev: p.sameAsPrev ?? inferredPrev,
sameAsNext: p.sameAsNext ?? inferredNext,
});
})}
</View>
);
}
);
MessageBubbleGroup.displayName = "MessageBubbleGroup";
| MessageBubble | |||
| variant? | MessageBubbleVariant | Bubble style. Default "soft". | |
| align? | MessageBubbleAlign | Which side. Default "start". | |
| animateIn? | boolean | Play an entrance animation when mounted. Default false. | |
| timestamp? | string | Optional timestamp shown below the bubble (exposed to screen readers). | |
| sender? | string | Optional sender name — exposed to screen readers, not rendered. | |
| onPress? | () => void | Press handler — bubbles can be tappable (e.g. to open actions). | |
| onReply? | () => void | Swipe-right callback — shows a reply icon during the gesture. | |
| onLongPress? | () => void | Long-press callback — for context menus / action sheets. | |
| sameAsPrev? | boolean | Previous bubble in the group shares this align (same sender run) — tightens the corner on the grouped side. Auto-inferred inside MessageBubbleGroup; set explicitly to override. | |
| sameAsNext? | boolean | Next bubble in the group shares this align — keeps the grouped corner tight through the whole run. | |
| enableSwipeReply? | boolean | Enable swipe-to-reply gesture. Default true when onReply is set. | |
| className? | string | ||
| textClassName? | string | ||
| children? | React.ReactNode | ||
| MessageBubbleGroup | |||
| spacing? | "compact" | "default" | Spacing between bubbles. Default "compact". | |
| className? | string | ||
| children? | React.ReactNode | ||
Chat message surface with variants (solid, soft, tint, outline, ghost, danger), alignment, entrance animation, and optional timestamp. Sent bubbles pop in with a restrained overshoot.
import { BubbleChat } from "@/components/ui/message-bubble/variants/chat";
export function Example() {
return (
<BubbleChat />
);
} import * as React from "react";
import { View } from "react-native";
import { MessageBubble, MessageBubbleGroup } from "../message-bubble";
import { Ambient, Glass, Text } from "@lumioui/ui";
/** BubbleChat — delivery chat thread with swipe-to-reply. */
export function BubbleChat() {
const [replyTo, setReplyTo] = React.useState<string | null>(null);
return (
<View className="relative w-full overflow-hidden rounded-3xl">
<Ambient seed="message-bubble-chat" />
<View className="w-full gap-3 p-4">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<View className="flex-row items-baseline justify-between">
<Text variant="small" className="font-semibold">Diego Ruiz · Order #8421</Text>
<Text variant="muted">Swipe right to reply</Text>
</View>
{replyTo ? <Text variant="small" className="text-primary">Replying to: {replyTo}</Text> : null}
<MessageBubbleGroup className="gap-2">
<MessageBubble align="start" variant="soft" timestamp="18:41" animateIn onReply={() => setReplyTo("Order #8421 is on its way 🍜")} onLongPress={() => {}}>Order #8421 is on its way 🍜 — Tonkotsu ×2 from Sakura Ramen</MessageBubble>
<MessageBubble align="end" variant="solid" timestamp="18:42" animateIn onReply={() => setReplyTo("How long to Alfama?")} onLongPress={() => {}}>How long to Alfama, Diego?</MessageBubble>
<MessageBubble align="start" variant="soft" timestamp="18:43" animateIn onReply={() => setReplyTo("12 min — parking near your door")} onLongPress={() => {}}>12 min — parking near your door now 🛵</MessageBubble>
<MessageBubble align="end" variant="solid" timestamp="18:43" animateIn onReply={() => setReplyTo("Perfect, see you at the door!")} onLongPress={() => {}}>Perfect, see you at the door!</MessageBubble>
</MessageBubbleGroup>
</Glass>
</View>
</View>
);
}
Chat message surface with variants (solid, soft, tint, outline, ghost, danger), alignment, entrance animation, and optional timestamp. Sent bubbles pop in with a restrained overshoot.
import { BubbleVariants } from "@/components/ui/message-bubble/variants/variants";
export function Example() {
return (
<BubbleVariants />
);
} import * as React from "react";
import { View } from "react-native";
import { MessageBubble, MessageBubbleGroup } from "../message-bubble";
import { Ambient, Glass, Text } from "@lumioui/ui";
/** BubbleVariants — all six bubble treatments. */
export function BubbleVariants() {
return (
<View className="relative w-full overflow-hidden rounded-3xl">
<Ambient seed="message-bubble-variants" />
<View className="w-full gap-3 p-4">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<Text variant="small" className="font-semibold">Variants</Text>
<MessageBubbleGroup>
<MessageBubble variant="solid">Solid</MessageBubble>
<MessageBubble variant="soft">Soft</MessageBubble>
<MessageBubble variant="tint">Tint</MessageBubble>
<MessageBubble variant="outline">Outline</MessageBubble>
<MessageBubble variant="ghost">Ghost</MessageBubble>
<MessageBubble variant="danger">Danger</MessageBubble>
</MessageBubbleGroup>
</Glass>
</View>
</View>
);
}