shadimage
gaia
uimessage-bubblesource: ok · hit

iOS Message Bubbles

Beautiful iOS-style message bubbles with authentic iMessage appearance, message grouping, and tail effects.

npx shadcn add "https://ui.heygaia.io/r/message-bubble.json"
free

Interaction

scroll-reveal

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files2 · source cached 10/14/2026, 8:42:06 AM

registry/new-york/ui/message-bubble.tsx
"use client";

import "./message-bubble.css";

import { cn } from "@/lib/utils";

export interface MessageBubbleProps {
	message: string;
	variant?: "sent" | "received";
	grouped?: "first" | "middle" | "last" | "none";
	className?: string;
	children?: React.ReactNode;
}

export function MessageBubble({
	message,
	variant = "received",
	grouped = "none",
	className,
	children,
}: MessageBubbleProps) {
	const groupedClasses =
		grouped === "first"
			? "imessage-grouped-first mb-1.5"
			: grouped === "last"
				? "imessage-grouped-last"
				: grouped === "middle"
					? "imessage-grouped-middle mb-1.5"
					: "";

	return (
		<div
			className={cn(
				"imessage-bubble",
				variant === "sent" ? "imessage-from-me" : "imessage-from-them",
				groupedClasses,
				className,
			)}
		>
			{children || <p className="whitespace-pre-wrap">{message}</p>}
		</div>
	);
}

export interface ChatMessageProps {
	timestamp?: string;
	messages: string[];
	variant?: "sent" | "received";
	className?: string;
	showTimestamp?: boolean;
}

export function ChatMessage({
	timestamp,
	messages,
	variant = "received",
	className,
	showTimestamp = true,
}: ChatMessageProps) {
	const hasMultipleMessages = messages.length > 1;

	const getGroupedType = (index: number, total: number) => {
		if (total === 1) return "none";
		if (index === 0) return "first";
		if (index === total - 1) return "last";
		return "middle";
	};

	return (
		<div className={cn("flex w-full flex-col", className)}>
			<div className="flex flex-col">
				{messages.map((message, index) => (
					<MessageBubble
						key={index}
						message={message}
						variant={variant}
						grouped={
							hasMultipleMessages
								? getGroupedType(index, messages.length)
								: "none"
						}
					/>
				))}
			</div>

			{showTimestamp && timestamp && (
				<span
					className={cn(
						"mt-1 px-2 text-xs text-muted-foreground",
						variant === "sent" && "text-right",
					)}
				>
					{timestamp}
				</span>
			)}
		</div>
	);
}
registry/new-york/ui/message-bubble.css
/* iOS-style message bubbles */
.imessage-bubble {
	word-wrap: break-word;
	line-height: 24px;
	position: relative;
	padding: 8px 20px;
	max-width: 60vw;
	border-radius: 20px;
}

.imessage-bubble::before,
.imessage-bubble::after {
	content: "";
	position: absolute;
	bottom: 0;
	height: 18px;
}

/* Grouped messages have tighter corner radii on the stacking side */
/* Received messages (left side) - tight corners on left side */
.imessage-grouped-first.imessage-from-them {
	/* biome-ignore lint/complexity/noImportantStyles: need to override radius */
	border-radius: 20px 20px 20px 5px !important;
}

.imessage-grouped-middle.imessage-from-them {
	/* biome-ignore lint/complexity/noImportantStyles: need to override radius */
	border-radius: 5px 20px 20px 5px !important;
}

.imessage-grouped-last.imessage-from-them {
	/* biome-ignore lint/complexity/noImportantStyles: need to override radius */
	border-radius: 5px 20px 20px 20px !important;
}

.imessage-grouped-last.imessage-from-them {
	/* biome-ignore lint/complexity/noImportantStyles: need to override radius */
	border-radius: 5px 20px 20px 20px !important;
}

/* Sent messages (right side) - tight corners on right side */
.imessage-grouped-first.imessage-from-me {
	/* biome-ignore lint/complexity/noImportantStyles: need to override radius */
	border-radius: 20px 20px 5px 20px !important;
}

.imessage-grouped-middle.imessage-from-me {
	/* biome-ignore lint/complexity/noImportantStyles: need to override radius */
	border-radius: 20px 5px 5px 20px !important;
}

.imessage-grouped-last.imessage-from-me {
	/* biome-ignore lint/complexity/noImportantStyles: need to override radius */
	border-radius: 20px 5px 20px 20px !important;
}

/* Bot messages (from-them style) */
.imessage-from-them {
	background-color: var(--color-zinc-300);
	color: black;
	align-self: flex-start;
}

.imessage-from-them::before {
	left: -7px;
	width: 20px;
	background-color: var(--color-zinc-300);
	border-bottom-right-radius: 16px;
}

.imessage-from-them::after {
	left: -26px;
	width: 26px;
	background-color: var(--color-background);
	border-bottom-right-radius: 10px;
}

/* User messages (from-me style) */
.imessage-from-me {
	color: black;
	background: #00bbff;
	align-self: flex-end;
}

.imessage-from-me::before {
	right: -7px;
	width: 20px;
	background-color: #00bbff;
	border-bottom-left-radius: 16px 14px;
}

.imessage-from-me::after {
	right: -26px;
	width: 26px;
	background-color: var(--color-background);
	border-bottom-left-radius: 10px;
}

/* Grouped iMessage bubbles for NEW_MESSAGE_BREAK */
/* Hide tails for grouped messages except the last one */
.imessage-grouped-first::before,
.imessage-grouped-first::after,
.imessage-grouped-middle::before,
.imessage-grouped-middle::after {
	display: none;
}

More from gaia

Browse all components in this library →