vllnt-ui
componentai-message-bubblesource: ok · hit
AI Message Bubble
Chat bubble for assistant, user, tool, and system messages using the current design system surfaces.
npx shadcn add "https://ui.vllnt.com/r/ai-message-bubble.json"free
Where it goes
chat
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@vllnt/ui@^0.3.0class-variance-authority
Files1 · source cached 10/9/2026, 1:27:58 PM
registry/default/ai-message-bubble/ai-message-bubble.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@vllnt/ui";
import { Avatar, AvatarFallback } from "@vllnt/ui";
import { Badge } from "@vllnt/ui";
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 = ({
author,
children,
className,
messageRole = "assistant",
ref,
status,
timestamp,
...props
}: AIMessageBubbleProps & { ref?: React.Ref<HTMLDivElement> }) => {
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 };