shadimage
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 };

More from vllnt-ui

Browse all components in this library →