AI Message Bubble
Render assistant, user, tool, and system messages in React. Markdown-ready chat bubbles for LLM and agent apps. Accessible, install via the shadcn CLI.
When to use this in an AI app
Use AI Message Bubble to render each turn in a conversation. Role variants (user, assistant, tool, system) keep an LLM transcript readable and consistent with the rest of your design system.
Browse all AI agent componentsPreview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/ai-message-bubble.jsonStorybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in StorybookCode
import { forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
import { Avatar, AvatarFallback } from "../avatar/avatar";
import { Badge } from "../badge";
const bubbleVariants = cva(
"rounded-2xl border px-4 py-3 shadow-sm transition-colors",
{
defaultVariants: {
messageRole: "assistant",
},
variants: {
messageRole: {
assistant: "border-border bg-card text-card-foreground",
system: "border-border/80 bg-muted/60 text-foreground",
tool: "border-border bg-muted/40 text-foreground",
user: "border-primary/20 bg-primary/10 text-foreground",
},
},
},
);
function AIMessageMeta({
author,
isUser,
status,
timestamp,
}: {
author?: string;
isUser: boolean;
status?: string;
timestamp?: string;
}) {
return (
<div
className={cn(
"flex flex-wrap items-center gap-2 text-xs text-muted-foreground",
isUser ? "justify-end" : "justify-start",
)}
>
{author ? (
<span className="font-medium text-foreground">{author}</span>
) : null}
{timestamp ? <span>{timestamp}</span> : null}
{status ? (
<Badge
className="rounded-full px-2 py-0 text-[10px]"
variant="secondary"
>
{status}
</Badge>
) : null}
</div>
);
}
export type AIMessageBubbleProps = React.ComponentPropsWithoutRef<"div"> &
VariantProps<typeof bubbleVariants> & {
/** Optional short label describing the speaker. */
author?: string;
/** Bubble body content. */
children: React.ReactNode;
/** Optional status badge for the message. */
status?: string;
/** Optional timestamp or relative time label. */
timestamp?: string;
};
const AIMessageBubble = forwardRef<HTMLDivElement, AIMessageBubbleProps>(
(
{
author,
children,
className,
messageRole = "assistant",
status,
timestamp,
...props
},
ref,
) => {
const resolvedMessageRole = messageRole ?? "assistant";
const isUser = resolvedMessageRole === "user";
const fallbackLabel = (author ?? resolvedMessageRole)
.charAt(0)
.toUpperCase();
return (
<div
className={cn("flex w-full", isUser ? "justify-end" : "justify-start")}
>
<div
className={cn(
"flex w-full max-w-3xl gap-3",
isUser ? "flex-row-reverse text-right" : "flex-row",
)}
>
<Avatar className="mt-0.5 size-8 border border-border/70">
<AvatarFallback className="bg-muted text-[11px] font-medium uppercase text-muted-foreground">
{fallbackLabel}
</AvatarFallback>
</Avatar>
<div
className={cn(
"min-w-0 space-y-2",
isUser ? "items-end" : "items-start",
)}
>
<AIMessageMeta
author={author}
isUser={isUser}
status={status}
timestamp={timestamp}
/>
<div
className={cn(
bubbleVariants({ messageRole: resolvedMessageRole }),
className,
)}
ref={ref}
{...props}
>
<div className="text-sm leading-6 whitespace-pre-wrap">
{children}
</div>
</div>
</div>
</div>
</div>
);
},
);
AIMessageBubble.displayName = "AIMessageBubble";
export { AIMessageBubble };