A chat message with user, assistant and system roles: right-aligned user bubbles, full-width assistant prose, an avatar slot, hover-revealed actions and a hydration-safe timestamp.
import { Message, MessageAvatar, MessageContent } from "@/components/ballmac/ai-message"
export default function AiMessageDemo() {
return (
<div className="flex w-full max-w-xl flex-col gap-6">
<Message role="user">
<MessageAvatar>YO</MessageAvatar>
<MessageContent>How do I keep a chat scrolled to the newest message while it streams?</MessageContent>
</Message>
<Message role="assistant">
<MessageAvatar>AI</MessageAvatar>
<MessageContent>
<p>
Stick to the bottom only while the reader is already there. Track whether the scroll position is within a few
pixels of the end; if it is, scroll down as new tokens arrive. If they scrolled up, leave them and show a
“jump to latest” button instead.
</p>
</MessageContent>
</Message>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/ai-messageInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/i18nCopy the source into your project.
components/ballmac/ai-message.tsx// Ballmac UI: AI Message. https://ui.ballmac.com/components/ai-message "use client" import * as React from "react" import { Check, Copy } from "lucide-react" import { Button } from "@/components/ballmac/button" import { cn } from "@/lib/utils" import { useLocale, useMessages } from "@/lib/ballmac/i18n" type MessageRole = "user" | "assistant" | "system" const MessageContext = React.createContext<{ role: MessageRole }>({ role: "assistant" }) type MessageProps = Omit<React.ComponentProps<"div">, "role"> & { /** Who sent the message. User messages sit right in a bubble; assistant messages run full width; system messages are centered notes. */ role?: MessageRole } function Message({ role = "assistant", className, ...props }: MessageProps) { const value = React.useMemo(() => ({ role }), [role]) return ( <MessageContext.Provider value={value}> <div data-slot="message" data-role={role} className={cn( "group/message grid w-full gap-y-1.5 has-[>[data-slot=message-avatar]]:gap-x-3", role === "user" && "grid-cols-[minmax(0,1fr)_auto]", role === "assistant" && "grid-cols-[auto_minmax(0,1fr)]", role === "system" && "grid-cols-1 justify-items-center", className )} {...props} /> </MessageContext.Provider> ) } /** Column placement shared by every part except the avatar. */ function useBodyPlacement() { const { role } = React.useContext(MessageContext) if (role === "user") return "col-start-1 justify-self-end" if (role === "assistant") return "col-start-2 justify-self-start" return "col-start-1" } type MessageAvatarProps = React.ComponentProps<"div"> function MessageAvatar({ className, ...props }: MessageAvatarProps) { const { role } = React.useContext(MessageContext) return ( <div data-slot="message-avatar" className={cn( "row-start-1 flex size-8 shrink-0 items-center justify-center self-start overflow-hidden rounded-full border bg-muted font-mono text-[11px] font-medium text-muted-foreground [&_img]:size-full [&_img]:object-cover [&_svg]:size-4", role === "user" ? "col-start-2" : "col-start-1", role === "system" && "hidden", className )} {...props} /> ) } type MessageContentProps = React.ComponentProps<"div"> function MessageContent({ className, ...props }: MessageContentProps) { const { role } = React.useContext(MessageContext) const placement = useBodyPlacement() return ( <div data-slot="message-content" className={cn( placement, "min-w-0 text-sm break-words", role === "user" && "max-w-[85%] rounded-xl rounded-ee-sm bg-muted px-3.5 py-2 leading-6 whitespace-pre-wrap text-foreground", role === "assistant" && "w-full leading-7 text-foreground [&_code]:font-mono [&_code]:text-[0.9em] [&_pre]:overflow-x-auto [&>*+*]:mt-3 [&_ol]:list-decimal [&_ol]:ps-5 [&_ul]:list-disc [&_ul]:ps-5", role === "system" && "rounded-md border border-dashed px-3 py-1.5 text-center font-mono text-xs text-muted-foreground", className )} {...props} /> ) } type MessageActionsProps = React.ComponentProps<"div"> & { /** "hover" reveals the row on hover or keyboard focus on devices with a mouse (always visible on touch). "always" keeps it visible. */ visibility?: "hover" | "always" } function MessageActions({ visibility = "hover", className, ...props }: MessageActionsProps) { const msg = useMessages() const { role } = React.useContext(MessageContext) const placement = useBodyPlacement() return ( <div data-slot="message-actions" role="toolbar" aria-label={msg("ai-message.messageActions", "Message actions")} className={cn( placement, "flex items-center gap-0.5", role === "assistant" && "-ms-1.5", visibility === "hover" && "transition-opacity duration-150 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within/message:opacity-100 [@media(hover:hover)]:group-hover/message:opacity-100", className )} {...props} /> ) } type MessageActionProps = Omit<React.ComponentProps<typeof Button>, "variant" | "size"> & { /** Accessible name for the icon-only button, also shown as a native tooltip. */ label: string } function MessageAction({ label, className, ...props }: MessageActionProps) { return ( <Button data-slot="message-action" type="button" variant="ghost" size="icon-sm" aria-label={label} title={label} className={cn( "size-7 text-muted-foreground hover:text-foreground aria-pressed:text-foreground focus-visible:ring-offset-0 [&_svg:not([class*='size-'])]:size-3.5", className )} {...props} /> ) } type MessageCopyActionProps = Omit<MessageActionProps, "label" | "onClick"> & { /** Text written to the clipboard, usually the message's raw markdown. */ value: string /** Accessible name before copying. */ label?: string /** Called after the text was copied. */ onCopied?: () => void } function MessageCopyAction({ value, label, onCopied, ...props }: MessageCopyActionProps) { const msg = useMessages() label ??= msg("ai-message.label", "Copy message") const [copied, setCopied] = React.useState(false) const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined) React.useEffect(() => () => clearTimeout(timer.current), []) async function copy() { try { await navigator.clipboard.writeText(value) } catch { return } setCopied(true) onCopied?.() clearTimeout(timer.current) timer.current = setTimeout(() => setCopied(false), 1800) } return ( <> <MessageAction label={copied ? "Copied" : label} onClick={copy} {...props}> {copied ? <Check /> : <Copy />} </MessageAction> <span className="sr-only" aria-live="polite"> {copied ? "Copied to clipboard" : ""} </span> </> ) } type MessageTimestampProps = Omit<React.ComponentProps<"time">, "children"> & { /** When the message was sent. */ date: Date | string | number /** BCP 47 locale for formatting. */ locale?: string /** IANA time zone. When omitted the viewer's zone is used, formatted after hydration so server and browser agree. */ timeZone?: string /** Intl.DateTimeFormat options. */ format?: Intl.DateTimeFormatOptions /** Preformatted text; overrides the built-in formatting. */ children?: React.ReactNode } const subscribeNothing = () => () => {} function MessageTimestamp({ date, locale, timeZone, format = { hour: "numeric", minute: "2-digit" }, className, children, ...props }: MessageTimestampProps) { const defaultLocale = useLocale() locale ??= defaultLocale const placement = useBodyPlacement() const isClient = React.useSyncExternalStore( subscribeNothing, () => true, () => false ) const d = new Date(date) const valid = !Number.isNaN(d.getTime()) const text = children ?? (valid && (timeZone || isClient) ? new Intl.DateTimeFormat(locale, { ...format, timeZone }).format(d) : null) return ( <time data-slot="message-timestamp" dateTime={valid ? d.toISOString() : undefined} className={cn(placement, "min-h-4 font-mono text-[11px] text-muted-foreground tabular-nums", className)} {...props} > {text} </time> ) } export { Message, MessageAvatar, MessageContent, MessageActions, MessageAction, MessageCopyAction, MessageTimestamp, type MessageRole, type MessageProps, type MessageAvatarProps, type MessageContentProps, type MessageActionsProps, type MessageActionProps, type MessageCopyActionProps, type MessageTimestampProps, }Update the import paths to match your project setup.
Usage
import { Message, MessageAvatar, MessageContent, MessageActions, MessageAction, MessageCopyAction, MessageTimestamp } from "@/components/ballmac/ai-message"The full example is in the Code tab above.
Examples
With actions
"use client"
import { RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react"
import { Message, MessageAction, MessageActions, MessageAvatar, MessageContent, MessageCopyAction, MessageTimestamp } from "@/components/ballmac/ai-message"
const reply = "Use `useTransition` to keep the input responsive while the list re-renders; wrap only the expensive state update in `startTransition`."
export default function AiMessageActions() {
return (
<div className="w-full max-w-xl">
<Message role="assistant">
<MessageAvatar>AI</MessageAvatar>
<MessageContent>
<p>
Use <code>useTransition</code> to keep the input responsive while the list re-renders; wrap only the expensive
state update in <code>startTransition</code>.
</p>
</MessageContent>
<MessageActions visibility="always">
<MessageCopyAction value={reply} />
<MessageAction label="Regenerate">
<RotateCcw />
</MessageAction>
<MessageAction label="Good response">
<ThumbsUp />
</MessageAction>
<MessageAction label="Bad response">
<ThumbsDown />
</MessageAction>
<MessageTimestamp date="2026-09-28T09:41:00Z" timeZone="UTC" />
</MessageActions>
</Message>
</div>
)
}API reference
<Message>
| Prop | Type | Default |
|---|---|---|
roleWho sent the message. User messages sit right in a bubble; assistant messages run full width; system messages are centered notes. | MessageRole | "assistant" |
<MessageActions>
| Prop | Type | Default |
|---|---|---|
visibility"hover" reveals the row on hover or keyboard focus on devices with a mouse (always visible on touch). "always" keeps it visible. | "hover" | "always" | "hover" |
<MessageAction>
| Prop | Type | Default |
|---|---|---|
label*Accessible name for the icon-only button, also shown as a native tooltip. | string | — |
<MessageCopyAction>
| Prop | Type | Default |
|---|---|---|
value*Text written to the clipboard, usually the message's raw markdown. | string | — |
labelAccessible name before copying. | string | — |
onCopiedCalled after the text was copied. | () => void | — |
<MessageTimestamp>
| Prop | Type | Default |
|---|---|---|
date*When the message was sent. | Date | string | number | — |
localeBCP 47 locale for formatting. | string | — |
timeZoneIANA time zone. When omitted the viewer's zone is used, formatted after hydration so server and browser agree. | string | — |
formatIntl.DateTimeFormat options. | Intl.DateTimeFormatOptions | { hour: "numeric", minute: "2-digit" } |
childrenPreformatted text; overrides the built-in formatting. | React.ReactNode | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Moves through the action buttons; focusing any of them reveals the action row |
| Enter / Space | Runs the focused action |
Use with AI
Render one chat turn. <Message role> wraps <MessageAvatar>, <MessageContent>, <MessageActions> (with <MessageAction label> and <MessageCopyAction value>) and <MessageTimestamp date>. It takes plain children, so it works with the Vercel AI SDK, LangChain or any stream. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI AI Message (@ballmac/ai-message) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Rendering each turn of an assistant or chatbot conversation
- Showing copy, regenerate and feedback buttons under an assistant reply
- A system note inside a conversation, such as 'Model switched to a smaller context window'
Not for
- The scrolling conversation layout itself (use ai-chat and put messages inside ChatMessages)
- Threaded comments or email-style lists where both sides look alike
Registry JSON: https://ui.ballmac.com/r/ai-message.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
AI Chat
The layout for a chat UI: a message log that sticks to the bottom while replies stream unless the reader scrolls up, a jump-to-latest button, an empty state with suggestions and a footer.
Prompt Input
An auto-growing chat input: Enter sends, Shift+Enter adds a line (IME-safe), the send button turns into Stop while streaming, with file attachment chips and a toolbar slot.
Streaming Text
Text that arrives progressively, with a blinking caret while streaming, preserved whitespace, aria-busy for screen readers and an optional typewriter reveal for demos.
Tool Call Card
Shows one agent tool call: the tool name, a pending, running, success or error status with icon and text, duration, and collapsible pretty-printed input and result.