ChatMessage
Composable chat message with stable inline editing, bubble or flat layout, grouped bursts, reactions, and replies.
Examples
Team conversation
People talking to people: layout="flat" keeps everyone on the start edge, continuation folds a burst under one author, and reactions and reply summaries hang off the body.
Composition
Editable message
ChatMessageEditable owns the plain-text draft and defaults to Copy/Edit actions. The host persists onSave; rejection keeps the draft for retry. Escape cancels, Ctrl/Cmd+Enter saves, and Enter inserts a newline. Optional children customize the read-mode ActionBar.
- <ChatMessage>
- <ChatMessageRow>
- <ChatMessageBody>
- <ChatMessageEditable>
- value={text} onSave={saveMessage}
- <ChatMessageEditable>
- <ChatMessageBody>
- <ChatMessageRow>
Message with actions
- <ChatMessage>
- <ChatMessageRow>
- <ChatMessageAvatar />
- <ChatMessageBody>
- <ChatMessageHeader />
- <ChatMessageContent>
- message content
- <ChatMessagePending />
- <ChatMessageActions>
- <ActionBar />
- <ChatMessageRow>
Team message
- <ChatMessage>
- from="participant" layout="flat"
- <ChatMessageRow>
- <ChatMessageAvatar>
- Avatar, or ChatMessageTime on a continuation
- <ChatMessageBody>
- <ChatMessageHeader>
- <ChatMessageAuthor />
- <ChatMessageTime />
- <ChatMessageContent>
- message content
- <ChatMessageFooter />
- <ChatMessageReactions>
- <ChatMessageReaction />
- <ChatMessageReplySummary />
- <ChatMessageReplies />
- <ChatMessageHeader>
- <ChatMessageActions>
- <ActionBar />
- <ChatMessageAvatar>
Typing indicator
- <ChatTypingIndicator>
- who is typing
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
Use this command to install the component.
npx shadcn@latest add https://control-ui.dev/r/chat-message.jsonUsage
import type { MastraDBMessage } from "@mastra/core/agent/message-list";import { type DynamicToolPart, type FilePart, MessageFactory, type MessageRoleRendererProps, type MessageRoleRenderers, type MessageStatusRenderers, type ToolInvocationPart,} from "@mastra/react";import type { ReactNode } from "react";import type { ActivityState } from "@/components/control-ui/activity";import { Activity, ActivityContent, ActivityDetail, ActivityDetailContent, ActivityDetailLabel, ActivityIcon, ActivityStatus, ActivityTitle, ActivityTrigger,} from "@/components/control-ui/activity";import { ChatMessage, ChatMessageBody, ChatMessageContent, ChatMessageRow } from "@/components/control-ui/chat-message";import type { ChatRole } from "@/components/control-ui/hooks/use-chat-message";import { InlineAttachment, InlineAttachmentContent, InlineAttachmentMedia, InlineAttachmentTitle,} from "@/components/control-ui/inline-attachment";import { SourceBadge } from "@/components/control-ui/source-badge";import { Text } from "@/components/control-ui/ui/typography";function renderJson(value: unknown) { return typeof value === "string" ? value : JSON.stringify(value, null, 2);}function activityState(state?: string): ActivityState { if (state === "result" || state === "output-available") return "success"; if (state === "output-error" || state === "output-denied") return "error"; if (state === "partial-call" || state === "input-streaming") return "running"; return "pending";}function renderMessage(from: ChatRole, children: ReactNode) { return ( <ChatMessage from={from}> <ChatMessageRow> <ChatMessageBody> <ChatMessageContent>{children}</ChatMessageContent> </ChatMessageBody> </ChatMessageRow> </ChatMessage> );}function isImageMediaType(mimeType: string) { return mimeType === "image" || mimeType.startsWith("image/");}// Mastra file parts carry base64 in `data`; a provider that already hands back a data URL passes straight through.function fileSrc({ mimeType, data }: FilePart) { return data.startsWith("data:") ? data : `data:${mimeType};base64,${data}`;}function renderFile(part: FilePart) { const name = "filename" in part && typeof part.filename === "string" ? part.filename : "Attachment"; return ( <InlineAttachment name={name}> {isImageMediaType(part.mimeType) ? <InlineAttachmentMedia src={fileSrc(part)} alt={name} /> : null} <InlineAttachmentContent> <InlineAttachmentTitle /> </InlineAttachmentContent> </InlineAttachment> );}function renderToolInvocation(part: ToolInvocationPart) { const invocation = part.toolInvocation; const state = activityState(invocation.state); return ( <Activity kind="tool" name={invocation.toolName} state={state}> <ActivityTrigger> <ActivityIcon /> <ActivityTitle /> <ActivityStatus className="sr-only" /> </ActivityTrigger> <ActivityContent> <ActivityDetail> <ActivityDetailLabel>Input</ActivityDetailLabel> <ActivityDetailContent format="code">{renderJson(invocation.rawInput ?? invocation.args)}</ActivityDetailContent> </ActivityDetail> {invocation.result !== undefined || invocation.errorText ? ( <ActivityDetail> <ActivityDetailLabel>Output</ActivityDetailLabel> <ActivityDetailContent className={state === "error" ? "text-destructive-text" : undefined}> {invocation.errorText ?? renderJson(invocation.result)} </ActivityDetailContent> </ActivityDetail> ) : null} </ActivityContent> </Activity> );}function renderDynamicTool(part: DynamicToolPart) { const state = activityState(part.state); const name = part.toolName ?? "tool"; return ( <Activity kind="tool" name={name} state={state}> <ActivityTrigger> <ActivityIcon /> <ActivityTitle /> <ActivityStatus className="sr-only" /> </ActivityTrigger> <ActivityContent> {part.input !== undefined ? ( <ActivityDetail> <ActivityDetailLabel>Input</ActivityDetailLabel> <ActivityDetailContent format="code">{renderJson(part.input)}</ActivityDetailContent> </ActivityDetail> ) : null} {part.output !== undefined ? ( <ActivityDetail> <ActivityDetailLabel>Output</ActivityDetailLabel> <ActivityDetailContent>{renderJson(part.output)}</ActivityDetailContent> </ActivityDetail> ) : null} </ActivityContent> </Activity> );}const messageRoles = { User: ({ children }: MessageRoleRendererProps) => renderMessage("user", children), Assistant: ({ children }: MessageRoleRendererProps) => renderMessage("assistant", children), System: ({ children }: MessageRoleRendererProps) => renderMessage("system", children), Signal: ({ children }: MessageRoleRendererProps) => renderMessage("tool", children),} satisfies MessageRoleRenderers;const messageStatus = { Tripwire: ({ text }) => ( <Text as="p" role="alert"> {text} </Text> ), Warning: ({ text }) => ( <Text as="p" role="status"> {text} </Text> ), Error: ({ text }) => ( <Text as="p" role="alert"> {text} </Text> ), Pending: ({ children }) => <div aria-busy="true">{children}</div>, Task: ({ passed }) => ( <Text as="p" role="status"> Task {passed ? "completed" : "needs another step"} </Text> ),} satisfies MessageStatusRenderers;export function Example({ message }: { message: MastraDBMessage }) { return ( <MessageFactory message={message} roles={messageRoles} status={messageStatus} Text={({ text }) => <span>{text}</span>} Reasoning={({ reasoning, state }) => ( <Activity kind="reasoning" state={state === "streaming" ? "running" : "success"}> <ActivityTrigger> <ActivityIcon /> <ActivityTitle>Reasoning</ActivityTitle> </ActivityTrigger> <ActivityContent>{reasoning}</ActivityContent> </Activity> )} File={renderFile} StepStart={() => <hr />} ToolInvocation={renderToolInvocation} DynamicTool={renderDynamicTool} SourceUrl={({ title, url }) => <SourceBadge href={url}>{title}</SourceBadge>} SourceDocument={({ title }) => <span>{title}</span>} Data={({ data }) => <pre>{renderJson(data)}</pre>} fallback={(part) => <span>Unsupported message part: {part.type}</span>} /> );}Dependencies
src/registry/hooks/use-chat-message.tsHooksrc/registry/sources/control-ui/chat-message/social.tsxSupportsrc/registry/sources/control-ui/effects.cssSupportaction-barComponentLive statuslive-statusPrimitiveRaw code
Installed component source and support files
"use client";import type { ComponentProps, CSSProperties, JSX } from "react";import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";import { ActionBar, ActionBarCopy, ActionBarEdit, ActionBarItem } from "@/components/control-ui/action-bar";import type { ChatMessageProps } from "@/components/control-ui/hooks/use-chat-message";import { ChatMessageStateProvider, chatAuthorLabels, chatMessageStatusLabels, chatMessageTransition, useChatMessage, useChatMessageContext, useChatThreadAnnounce,} from "@/components/control-ui/hooks/use-chat-message";import type { ChatMessageKnobStyle } from "@/components/control-ui/knob-contracts/chat-message-knobs";import { cn } from "@/components/control-ui/lib/cn";import { LiveStatus } from "@/components/control-ui/ui/live-status";// biome-ignore lint/performance/noBarrelFile: Preserve the chat message install-facing API.export { ChatMessageReaction, type ChatMessageReactionProps, ChatMessageReactions, type ChatMessageReactionsProps, ChatMessageReplies, type ChatMessageRepliesProps, ChatMessageReplySummary, type ChatMessageReplySummaryProps, ChatTypingIndicator, type ChatTypingIndicatorProps,} from "@/components/control-ui/chat-message/social";export type ChatMessagePartProps<Element extends keyof JSX.IntrinsicElements> = Omit<ComponentProps<Element>, "style"> & { style?: CSSProperties & ChatMessageKnobStyle;};export function ChatMessage({ from, state = "idle", density = "comfortable", layout = "bubble", continuation = false, authorLabel = chatAuthorLabels[from], statusLabels, className, children, ...props}: ChatMessageProps) { const message = useChatMessage({ from, state, density, layout, continuation }); const announce = useChatThreadAnnounce(); const previousState = useRef(state); const pendingLabel = statusLabels?.pending ?? chatMessageStatusLabels.pending; const repliedLabel = statusLabels?.replied ?? chatMessageStatusLabels.replied; const errorLabel = statusLabels?.error ?? chatMessageStatusLabels.error; useEffect(() => { const previous = previousState.current; previousState.current = state; if (from === "user" || from === "participant") return; const announcement = chatMessageTransition(previous, state, { pending: pendingLabel, replied: repliedLabel, error: errorLabel }); if (announcement !== null) announce(announcement); }, [announce, from, state, pendingLabel, repliedLabel, errorLabel]); return ( <ChatMessageStateProvider value={message}> <article data-control-ui="chat-message" data-control-family="chat-message" data-slot="root" data-role={from} data-state={state} data-density={density} data-layout={layout} data-continuation={continuation ? "" : undefined} aria-label={authorLabel} className={cn("w-full", className)} {...props} > {children} </article> </ChatMessageStateProvider> );}export type ChatMessageRowProps = ChatMessagePartProps<"div">;export function ChatMessageRow({ className, children, ...props }: ChatMessageRowProps) { const message = useChatMessageContext(); return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="row" data-layout={message.layout} data-density={message.density} data-continuation={message.continuation ? "" : undefined} className={cn("relative flex w-full", message.isEndAligned ? "justify-end" : "justify-start", className)} {...props} > {children} </div> );}export type ChatMessageAvatarProps = ChatMessagePartProps<"div">;export function ChatMessageAvatar({ className, ...props }: ChatMessageAvatarProps) { const message = useChatMessageContext(); return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="avatar" data-layout={message.layout} data-continuation={message.continuation ? "" : undefined} className={cn("flex shrink-0 items-center justify-center", className)} {...props} /> );}export type ChatMessageBodyProps = ChatMessagePartProps<"div">;export function ChatMessageBody({ className, ...props }: ChatMessageBodyProps) { const message = useChatMessageContext(); return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="body" data-layout={message.layout} data-role={message.from} className={cn("flex min-w-0 flex-col", className)} {...props} /> );}export type ChatMessageHeaderProps = ChatMessagePartProps<"div">;export function ChatMessageHeader({ className, ...props }: ChatMessageHeaderProps) { const message = useChatMessageContext(); return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="header" data-layout={message.layout} data-role={message.from} data-state={message.state} className={cn("flex min-w-0 flex-wrap items-baseline", className)} {...props} /> );}export type ChatMessageAuthorProps = ChatMessagePartProps<"span">;export function ChatMessageAuthor({ className, ...props }: ChatMessageAuthorProps) { const message = useChatMessageContext(); return ( <span data-control-ui="chat-message" data-control-family="chat-message" data-slot="author" data-layout={message.layout} className={cn("truncate", className)} {...props} /> );}export type ChatMessageTimeProps = ChatMessagePartProps<"time"> & { dateTime: string;};export function ChatMessageTime({ className, ...props }: ChatMessageTimeProps) { return ( <time data-control-ui="chat-message" data-control-family="chat-message" data-slot="time" className={cn("whitespace-nowrap tabular-nums", className)} {...props} /> );}export type ChatMessageContentProps = ChatMessagePartProps<"div">;export function ChatMessageContent({ className, ...props }: ChatMessageContentProps) { const message = useChatMessageContext(); return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="content" data-role={message.from} data-streaming={message.isStreaming ? "" : undefined} className={cn(message.isBubble && "px-[var(--padding-x)] py-[var(--padding-y)]", className)} {...props} /> );}export type ChatMessageEditableProps = Omit<ChatMessagePartProps<"div">, "onError"> & { value: string; /** Persist the text in the host. Reject to keep the draft open for retry. */ onSave: (value: string) => void | Promise<void>; onSaveError?: (error: unknown) => void; editorLabel?: string; saveLabel?: string; cancelLabel?: string; savingLabel?: string; saveErrorLabel?: string;};function useMessageEditor({ value, onSave, onSaveError }: Pick<ChatMessageEditableProps, "value" | "onSave" | "onSaveError">) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); const [originalValue, setOriginalValue] = useState(value); const [saving, setSaving] = useState(false); const [failed, setFailed] = useState(false); const inputRef = useRef<HTMLTextAreaElement>(null); const triggerRef = useRef<HTMLElement | null>(null); const savingRef = useRef(false); const errorId = useId(); useLayoutEffect(() => { if (editing) { const input = inputRef.current; input?.focus({ preventScroll: true }); input?.setSelectionRange(input.value.length, input.value.length); } else if (triggerRef.current) { triggerRef.current.focus({ preventScroll: true }); triggerRef.current = null; } }, [editing]); function startEditing() { if (savingRef.current) return; triggerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; setDraft(value); setOriginalValue(value); setFailed(false); setEditing(true); } function cancelEditing() { if (savingRef.current) return; setFailed(false); setEditing(false); } async function save() { if (!editing || savingRef.current || !draft.trim()) return; if (draft === value) { cancelEditing(); return; } savingRef.current = true; setSaving(true); setFailed(false); try { await onSave(draft); setEditing(false); } catch (error) { setFailed(true); onSaveError?.(error); } finally { savingRef.current = false; setSaving(false); } } return { editing, draft, setDraft, saving, failed, inputRef, errorId, startEditing, cancelEditing, save, displayValue: editing ? originalValue : value, };}/** Plain-text editing in the message surface. Children replace the default Copy/Edit actions. */export function ChatMessageEditable({ value, onSave, onSaveError, editorLabel = "Edit message", saveLabel = "Save", cancelLabel = "Cancel", savingLabel = "Saving…", saveErrorLabel = "Couldn't save your message. Try again.", children, className, ...props}: ChatMessageEditableProps) { const message = useChatMessageContext(); const { editing, draft, setDraft, saving, failed, inputRef, errorId, startEditing, cancelEditing, save, displayValue } = useMessageEditor( { value, onSave, onSaveError }, ); return ( <div {...props} data-control-ui="chat-message" data-control-family="chat-message" data-slot="editable" data-editing={editing ? "" : undefined} className={cn("min-w-0", className)} > <ChatMessageContent data-editable=""> <span data-control-ui="chat-message" data-control-family="chat-message" data-slot="edit-value" aria-hidden={editing}> {displayValue || "\u200b"} </span> <span data-control-ui="chat-message" data-control-family="chat-message" data-slot="edit-sizer" aria-hidden="true"> {`${draft}\u200b`} </span> <textarea ref={inputRef} data-control-ui="chat-message" data-control-family="chat-message" data-slot="edit-input" aria-keyshortcuts="Control+Enter Meta+Enter Escape" aria-label={editorLabel} aria-describedby={failed ? errorId : undefined} aria-hidden={!editing} aria-busy={saving} tabIndex={editing ? 0 : -1} readOnly={!editing || saving} value={editing ? draft : value} rows={1} onChange={(event) => setDraft(event.currentTarget.value)} onKeyDown={(event) => { if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); cancelEditing(); } else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); void save(); } }} /> </ChatMessageContent> <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="edit-toolbars"> <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="edit-toolbar" data-active={!editing ? "" : undefined} inert={editing} aria-hidden={editing} > <ActionBar align={message.isEndAligned ? "end" : "start"} copyValue={value} onEdit={startEditing}> {children ?? ( <> <ActionBarCopy /> <ActionBarEdit /> </> )} </ActionBar> </div> <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="edit-toolbar" data-active={editing ? "" : undefined} inert={!editing} aria-hidden={!editing} > <ActionBar align={message.isEndAligned ? "end" : "start"} label="Edit actions" aria-busy={saving}> <ActionBarItem type="button" disabled={saving || !draft.trim()} onClick={() => void save()}> {saveLabel} </ActionBarItem> <ActionBarItem type="button" disabled={saving} onClick={cancelEditing}> {cancelLabel} </ActionBarItem> </ActionBar> </div> </div> <LiveStatus message={saving ? savingLabel : ""} /> <span id={errorId} role="alert" data-control-ui="chat-message" data-control-family="chat-message" data-slot="edit-error" hidden={!failed} > {failed ? saveErrorLabel : ""} </span> </div> );}export type ChatMessagePendingProps = Omit<ChatMessagePartProps<"div">, "children">;/** Visual only: the pending transition is announced by `ChatMessage` through the thread's status region. */export function ChatMessagePending({ className, ...props }: ChatMessagePendingProps) { const message = useChatMessageContext(); if (!message.isPending) return null; return ( <div aria-hidden="true" data-control-ui="chat-message" data-control-family="chat-message" data-slot="pending" className={cn("inline-flex items-center", className)} {...props} > <span /> <span /> <span /> </div> );}export type ChatMessageFooterProps = ChatMessagePartProps<"div">;export function ChatMessageFooter({ className, ...props }: ChatMessageFooterProps) { const message = useChatMessageContext(); return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="footer" data-layout={message.layout} data-role={message.from} data-state={message.state} className={cn("flex flex-wrap items-center", className)} {...props} /> );}export type ChatMessageActionsProps = ChatMessagePartProps<"div">;export function ChatMessageActions({ className, children, ...props }: ChatMessageActionsProps) { const { layout } = useChatMessageContext(); return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="actions" data-layout={layout} className={cn("flex items-center", className)} {...props} > {layout === "flat" ? ( <div aria-hidden="true" data-control-ui="chat-message" data-control-family="popup" data-popup-kind="toolbar" data-popup-part="surface" data-popup-static="" data-surface="floating" data-slot="actions-surface" className="pointer-events-none absolute inset-0 -z-1" /> ) : null} {children} </div> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-chat-message-* · 21 knobsHow the cascade resolves--cui-chat-message-avatar-radius<length-percentage>9999px--cui-chat-message-avatar-background<color>var(--card)--cui-chat-message-avatar-border-color<color>var(--border)--cui-chat-message-avatar-border-width<length>var(--control-rim-width)--cui-chat-message-radius<length-percentage>0px--cui-chat-message-corner-radius<length-percentage>0px--cui-chat-message-background<color>transparent--cui-chat-message-background-image*none--cui-chat-message-foreground<color>var(--foreground)--cui-chat-message-border-color<color>transparent--cui-chat-message-border-width<length>0px--cui-chat-message-shadow*none--cui-chat-message-pending-dot-color<color>var(--muted-foreground)--cui-chat-message-pending-dot-size<length-percentage>calc(var(--spacing) * 1.5)--cui-chat-message-row-hover-background<color>oklch(from var(--muted) l c h / 0.6)--cui-chat-message-reaction-radius<length-percentage>9999px--cui-chat-message-reaction-background<color>oklch(from var(--muted) l c h / 0.7)--cui-chat-message-reaction-border-color<color>transparent--cui-chat-message-reaction-border-width<length>var(--control-rim-width)--cui-chat-message-reaction-pressed-background<color>oklch(from var(--primary) l c h / 0.14)--cui-chat-message-reaction-pressed-border-color<color>oklch(from var(--primary) l c h / 0.55)