AI & Chat

Message Bubble

A chat message row with role-aware alignment, avatar, timestamp, and a hover-revealed action row that fades and rises in 100ms.

Install

npx shadcn@latest add @paragon/message-bubble

message-bubble.tsx

"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";

export interface MessageBubbleProps
  extends Omit<
    React.ComponentProps<"div">,
    "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
  > {
  /** Who sent the message. Users align right, assistants left. */
  from?: "user" | "assistant";
  /** Display name — used for the avatar fallback initials and screen readers. */
  name?: string;
  /** Custom avatar node. Falls back to initials (user) or a spark (assistant). */
  avatar?: React.ReactNode;
  timestamp?: string;
  /** Hover-revealed action row — compose with MessageBubbleAction. */
  actions?: React.ReactNode;
  /** Entrance stagger delay in seconds, for lists. */
  delay?: number;
  /** Disables the enter animation. */
  static?: boolean;
}

/**
 * A chat message row: avatar, bubble, timestamp, and a hover-revealed action
 * row. Enters once with the house blur-rise. Actions fade + rise in 100ms on
 * hover (hover-capable pointers only — touch keeps them visible) and stay
 * revealed while focused, so keyboard users never lose them. The hidden
 * actions keep their layout box, so revealing them never shifts the row.
 */
export function MessageBubble({
  from = "assistant",
  name,
  avatar,
  timestamp,
  actions,
  delay = 0,
  static: isStatic = false,
  className,
  children,
  ...props
}: MessageBubbleProps) {
  const reducedMotion = useReducedMotion();
  const isUser = from === "user";

  const initials = React.useMemo(() => {
    if (!name) return isUser ? "You"[0] : null;
    return name
      .split(/\s+/)
      .slice(0, 2)
      .map((part) => part[0] ?? "")
      .join("")
      .toUpperCase();
  }, [name, isUser]);

  return (
    <motion.div
      data-slot="message-bubble"
      initial={
        isStatic
          ? false
          : reducedMotion
            ? { opacity: 0 }
            : { opacity: 0, y: 12, filter: "blur(4px)" }
      }
      animate={
        reducedMotion
          ? { opacity: 1 }
          : { opacity: 1, y: 0, filter: "blur(0px)" }
      }
      transition={{ type: "spring", duration: 0.35, bounce: 0, delay }}
      className={cn(
        "group flex w-full gap-2.5",
        isUser && "flex-row-reverse",
        className,
      )}
      {...props}
    >
      <span className="sr-only">{name ?? (isUser ? "You" : "Assistant")}</span>
      <div
        aria-hidden
        className={cn(
          "flex size-7 shrink-0 items-center justify-center rounded-full text-[11px] font-medium select-none",
          isUser
            ? "bg-primary text-primary-foreground"
            : "bg-muted text-muted-foreground",
        )}
      >
        {avatar ?? (isUser ? initials : <Sparkles className="size-3.5" />)}
      </div>
      <div
        className={cn(
          "flex min-w-0 max-w-[85%] flex-col gap-1",
          isUser && "items-end",
        )}
      >
        <div
          className={cn(
            "text-sm leading-relaxed",
            isUser
              ? "rounded-xl rounded-tr-sm bg-primary px-3.5 py-2 text-primary-foreground"
              : "py-0.5 text-foreground",
          )}
        >
          {children}
        </div>
        {(actions || timestamp) && (
          <div
            className={cn(
              "flex h-6 items-center gap-1",
              isUser && "flex-row-reverse",
            )}
          >
            {actions && (
              <div
                className={cn(
                  "flex items-center gap-0.5 transition-[opacity,translate] duration-100 ease-out",
                  // Hidden until hover only where hover exists; always visible on touch.
                  "[@media(hover:hover)_and_(pointer:fine)]:translate-y-1 [@media(hover:hover)_and_(pointer:fine)]:opacity-0",
                  "group-hover:translate-y-0 group-hover:opacity-100",
                  "focus-within:translate-y-0 focus-within:opacity-100",
                )}
              >
                {actions}
              </div>
            )}
            {timestamp && (
              <span className="px-1 text-[11px] text-muted-foreground tabular-nums">
                {timestamp}
              </span>
            )}
          </div>
        )}
      </div>
    </motion.div>
  );
}

export interface MessageBubbleActionProps
  extends React.ComponentProps<"button"> {
  /** Accessible name for the icon-only action. */
  label: string;
  /** Disables press/scale motion. */
  static?: boolean;
}

/** Icon action for the hover row — copy, retry, feedback. */
export function MessageBubbleAction({
  label,
  className,
  type = "button",
  static: isStatic = false,
  children,
  ...props
}: MessageBubbleActionProps) {
  return (
    <button
      type={type}
      aria-label={label}
      data-slot="message-bubble-action"
      className={cn(
        "relative flex size-6 items-center justify-center rounded-md text-muted-foreground transition-[background-color,color,scale] duration-150 ease-out hover:bg-accent hover:text-foreground after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-x-1/2 after:-translate-y-1/2 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0",
        !isStatic && "active:not-disabled:scale-[0.95]",
        className,
      )}
      {...props}
    >
      {children}
    </button>
  );
}