A chat message row with role-aware alignment, avatar, timestamp, and a hover-revealed action row that fades and rises in 100ms.
npx shadcn@latest add @paragon/message-bubble"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>
);
}