# Chat Message

One chat turn: a user bubble on the right or an assistant reply with an avatar, light formatting for bold and inline code, and copy, retry and feedback actions that appear on hover.

**Category:** AI  
**Exports:** `ChatMessage`  
**npm dependencies:** motion, lucide-react (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/chat-message.json
```
The file lands at `components/ui/chat-message.tsx`; import from `@/components/ui/chat-message`.

## API — Chat Message
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `role` | `"user" \| "assistant"` | `"assistant"` |  |
| `name` | `string` | `"Assistant"` |  |
| `avatar` | `string` | — |  |
| `children` | `ReactNode` | — | Strings render **bold**, `code` and line breaks. |
| `time` | `string` | — |  |
| `status` | `"sending" \| "sent" \| "error"` | `"sent"` |  |
| `actions` | `boolean` | `true` |  |
| `onRetry` | `() => void` | — |  |
| `onFeedback` | `(value: "up" \| "down" \| null) => void` | — |  |

## Example — Chat Message

```tsx
import { ChatMessage } from "@/components/ui/chat-message";

export default function ChatMessageDemo({ name = "Quill", actions = true, status = "sent" }: { name?: string; actions?: boolean; status?: "sending" | "sent" | "error" }) {
  return (
    <div className="flex w-full max-w-lg flex-col gap-5">
      <ChatMessage role="user" time="9:41" status={status}>
        Why did sign-ups dip last Tuesday?
      </ChatMessage>
      <ChatMessage name={name} time="9:41" actions={actions} onRetry={() => {}}>
        {"Sign-ups fell **31%** on Tuesday because the `/signup` page returned errors for 47 minutes after the 2:10pm deploy.\n\nTraffic was normal, so nothing was lost upstream."}
      </ChatMessage>
    </div>
  );
}
```

## Source — components/ui/chat-message.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
import { cn } from "@/lib/utils";

export type ChatMessageProps = {
  role?: "user" | "assistant";
  /** Shown beside assistant messages. */
  name?: string;
  /** Image URL; initials are drawn when left out. */
  avatar?: string;
  /** Text renders `inline code`, **bold** and line breaks; pass nodes for anything richer. */
  children: React.ReactNode;
  time?: string;
  status?: "sending" | "sent" | "error";
  /** Show copy, retry and feedback actions under assistant messages. */
  actions?: boolean;
  onRetry?: () => void;
  onFeedback?: (value: "up" | "down" | null) => void;
  className?: string;
};

function renderText(text: string) {
  // Light formatting only: **bold** and `code`, keeping everything else as typed.
  return text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).map((part, i) =>
    part.startsWith("**") ? (
      <strong key={i} className="font-semibold">
        {part.slice(2, -2)}
      </strong>
    ) : part.startsWith("`") ? (
      <code key={i} className="rounded-md bg-foreground/10 px-1 py-0.5 font-mono text-[0.85em]">
        {part.slice(1, -1)}
      </code>
    ) : (
      part
    ),
  );
}

const initials = (n: string) =>
  n
    .split(" ")
    .map((p) => p[0])
    .join("")
    .slice(0, 2)
    .toUpperCase();

/** One chat turn: a user bubble on the right, or an assistant reply with an avatar, light formatting and copy, retry and feedback actions. */
export function ChatMessage({ role = "assistant", name = "Assistant", avatar, children, time, status = "sent", actions = true, onRetry, onFeedback, className }: ChatMessageProps) {
  const reduce = useReducedMotion();
  const [copied, setCopied] = React.useState(false);
  const [vote, setVote] = React.useState<"up" | "down" | null>(null);
  const textRef = React.useRef<HTMLDivElement>(null);
  const user = role === "user";
  const body = typeof children === "string" ? renderText(children) : children;

  const copy = async () => {
    await navigator.clipboard?.writeText(textRef.current?.innerText ?? "");
    setCopied(true);
    setTimeout(() => setCopied(false), 1400);
  };
  const cast = (v: "up" | "down") => {
    const next = vote === v ? null : v;
    setVote(next);
    onFeedback?.(next);
  };

  return (
    <motion.div
      initial={reduce ? false : { opacity: 0, y: 8, scale: 0.98 }}
      animate={{ opacity: 1, y: 0, scale: 1 }}
      transition={{ type: "spring", stiffness: 380, damping: 30 }}
      className={cn("group flex w-full gap-3", user && "flex-row-reverse", className)}
    >
      {!user && (
        <span className="mt-0.5 grid size-8 shrink-0 place-items-center overflow-hidden rounded-full bg-ember text-[11px] font-semibold text-ember-foreground">
          {avatar ? <img src={avatar} alt="" className="size-full object-cover" /> : initials(name)}
        </span>
      )}
      <div className={cn("flex min-w-0 max-w-[85%] flex-col gap-1.5", user && "items-end")}>
        {!user && (
          <p className="text-xs text-muted-foreground">
            <span className="font-medium text-foreground">{name}</span>
            {time && <span> · {time}</span>}
          </p>
        )}
        <div
          ref={textRef}
          className={cn(
            "whitespace-pre-wrap break-words text-sm leading-6",
            user ? "rounded-2xl rounded-br-md bg-foreground px-4 py-2.5 text-background" : "text-foreground",
            status === "sending" && "opacity-60",
            status === "error" && "ring-1 ring-destructive/60",
          )}
        >
          {body}
        </div>
        {user && (time || status !== "sent") && (
          <p className={cn("text-[11px]", status === "error" ? "text-destructive" : "text-muted-foreground")}>{status === "error" ? "Not sent · tap retry" : status === "sending" ? "Sending…" : time}</p>
        )}
        {!user && actions && (
          <div className="-ml-1.5 flex items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
            <Action label={copied ? "Copied" : "Copy"} onClick={copy}>
              <AnimatePresence mode="wait" initial={false}>
                <motion.span key={copied ? "y" : "n"} initial={{ scale: 0.5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.5, opacity: 0 }} className="grid">
                  {copied ? <Check className="size-3.5 text-success" /> : <Copy className="size-3.5" />}
                </motion.span>
              </AnimatePresence>
            </Action>
            {onRetry && (
              <Action label="Retry" onClick={onRetry}>
                <RotateCcw className="size-3.5" />
              </Action>
            )}
            <Action label="Good response" pressed={vote === "up"} onClick={() => cast("up")}>
              <ThumbsUp className={cn("size-3.5", vote === "up" && "fill-current text-ember")} />
            </Action>
            <Action label="Bad response" pressed={vote === "down"} onClick={() => cast("down")}>
              <ThumbsDown className={cn("size-3.5", vote === "down" && "fill-current text-ember")} />
            </Action>
          </div>
        )}
      </div>
    </motion.div>
  );
}

function Action({ label, pressed, onClick, children }: { label: string; pressed?: boolean; onClick: () => void; children: React.ReactNode }) {
  return (
    <motion.button
      type="button"
      aria-label={label}
      title={label}
      aria-pressed={pressed}
      onClick={onClick}
      whileTap={{ scale: 0.85 }}
      className="grid size-7 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
    >
      {children}
    </motion.button>
  );
}
```