The small bar under an AI reply: copy, try again, good or bad answer (with reasons), branch, share. A real toolbar with arrow-key movement, spoken results, and choices taken back with the reason when they cannot be saved.
Your refund of $340 was issued on 14 October and returned by the bank because the card had expired. I have re-issued it as store credit, which is in your account now.
"use client"
import { MessageActions } from "@/components/ballmac/message-actions"
const answer = "Your refund of $340 was issued on 14 October and returned by the bank because the card had expired. I have re-issued it as store credit, which is in your account now."
export default function MessageActionsDemo() {
return (
<div className="grid w-full max-w-lg gap-2">
<div className="flex gap-3">
<span aria-hidden="true" className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">A</span>
<div className="grid gap-2">
<p className="rounded-2xl rounded-ss-md bg-muted px-4 py-2.5 text-sm leading-relaxed">{answer}</p>
<MessageActions
text={answer}
onRegenerate={() => new Promise((resolve) => setTimeout(resolve, 900))}
onFeedback={() => new Promise((resolve) => setTimeout(resolve, 400))}
onBranch={() => new Promise((resolve) => setTimeout(resolve, 300))}
onShare={() => new Promise((resolve) => setTimeout(resolve, 300))}
/>
</div>
</div>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/message-actionsInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/message-actions.tsx// Ballmac UI: Message Actions. https://ui.ballmac.com/components/message-actions "use client" import * as React from "react" import { Check, Copy, GitBranch, Loader2, RefreshCw, Share2, ThumbsDown, ThumbsUp } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type Feedback = "up" | "down" type MessageActionsProps = Omit<React.ComponentProps<"div">, "onChange"> & { /** The message text, copied by Copy. Leave it out to hide Copy. */ text?: string /** Adds Regenerate. Reject and the bar says why. */ onRegenerate?: () => void | Promise<void> /** Adds the thumbs. Called with the choice, and for a thumbs down the reason picked. Reject and the choice is taken back. */ onFeedback?: (value: Feedback, reason?: string) => void | Promise<void> /** Adds Branch, which continues the conversation from here. */ onBranch?: () => void | Promise<void> /** Adds Share. */ onShare?: () => void | Promise<void> /** The reasons offered after a thumbs down. */ reasons?: string[] /** The feedback already given, if any. */ defaultFeedback?: Feedback | null } const DEFAULT_REASONS = ["Not accurate", "Not helpful", "Too long", "Wrong tone", "Unsafe"] /** * The small bar under an AI reply: copy it, try again, say it was good or bad, branch from it, share it. It is a real toolbar: one stop in the tab order, * and Left, Right, Home and End move between the buttons. Every result is said aloud and shown ("Copied", "Thanks, that helps"); a thumbs down asks what was wrong * and a choice that cannot be saved is taken back with the reason. */ function MessageActions({ text, onRegenerate, onFeedback, onBranch, onShare, reasons = DEFAULT_REASONS, defaultFeedback = null, className, ...props }: MessageActionsProps) { const msg = useMessages() const id = React.useId() const [copied, setCopied] = React.useState(false) const [busy, setBusy] = React.useState<string | null>(null) const [feedback, setFeedback] = React.useState<Feedback | null>(defaultFeedback) const [asking, setAsking] = React.useState(false) const [status, setStatus] = React.useState<{ text: string; error?: boolean } | null>(null) const refs = React.useRef<HTMLButtonElement[]>([]) const [active, setActive] = React.useState(0) async function run(key: string, fn: () => void | Promise<void>, ok?: string) { setBusy(key) setStatus(null) try { await fn() if (ok) setStatus({ text: ok }) return true } catch (e) { setStatus({ text: e instanceof Error ? e.message : msg("message-actions.failed", "That did not work. Nothing was changed."), error: true }) return false } finally { setBusy(null) } } async function copy() { if (text === undefined) return try { await navigator.clipboard.writeText(text) setCopied(true) setStatus({ text: msg("message-actions.copied", "Copied") }) setTimeout(() => setCopied(false), 1600) } catch { setStatus({ text: msg("message-actions.copyFailed", "It could not be copied. Select the text and copy it yourself."), error: true }) } } async function rate(value: Feedback, reason?: string) { const before = feedback setFeedback(value) setAsking(value === "down" && reason === undefined) if (value === "down" && reason === undefined) return const ok = await run("feedback", () => onFeedback?.(value, reason), msg("message-actions.thanks", "Thanks, that helps.")) if (!ok) { setFeedback(before); setAsking(false) } else setAsking(false) } const buttons: { key: string; label: string; icon: React.ReactNode; pressed?: boolean; onClick: () => void; disabled?: boolean }[] = [ ...(text !== undefined ? [{ key: "copy", label: msg("message-actions.copy", "Copy"), icon: copied ? <Check /> : <Copy />, onClick: () => void copy() }] : []), ...(onRegenerate ? [{ key: "regen", label: msg("message-actions.regenerate", "Try again"), icon: busy === "regen" ? <Loader2 className="animate-spin" /> : <RefreshCw />, disabled: busy === "regen", onClick: () => void run("regen", onRegenerate, msg("message-actions.regenerated", "A new answer is on its way.")) }] : []), ...(onFeedback ? [ { key: "up", label: msg("message-actions.good", "Good answer"), icon: <ThumbsUp />, pressed: feedback === "up", onClick: () => void rate("up") }, { key: "down", label: msg("message-actions.bad", "Bad answer"), icon: <ThumbsDown />, pressed: feedback === "down", onClick: () => void rate("down") }, ] : []), ...(onBranch ? [{ key: "branch", label: msg("message-actions.branch", "Branch from here"), icon: <GitBranch />, onClick: () => void run("branch", onBranch, msg("message-actions.branched", "A new conversation started from here.")) }] : []), ...(onShare ? [{ key: "share", label: msg("message-actions.share", "Share"), icon: <Share2 />, onClick: () => void run("share", onShare) }] : []), ] if (buttons.length === 0) return null function onKeyDown(e: React.KeyboardEvent) { const last = buttons.length - 1 let next = active if (e.key === "ArrowRight") next = active === last ? 0 : active + 1 else if (e.key === "ArrowLeft") next = active === 0 ? last : active - 1 else if (e.key === "Home") next = 0 else if (e.key === "End") next = last else return e.preventDefault() setActive(next) refs.current[next]?.focus() } return ( <div data-slot="message-actions" className={cn("grid gap-2", className)} {...props}> <div role="toolbar" aria-label={msg("message-actions.label", "Message actions")} onKeyDown={onKeyDown} className="flex w-fit items-center gap-0.5 rounded-lg border bg-card p-0.5"> {buttons.map((b, i) => ( <button key={b.key} ref={(el) => { if (el) refs.current[i] = el }} type="button" aria-label={b.label} title={b.label} aria-pressed={b.pressed} disabled={b.disabled} tabIndex={active === i ? 0 : -1} onFocus={() => setActive(i)} onClick={b.onClick} className={cn("flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-60 motion-reduce:transition-none [&>svg]:size-4", b.pressed && "bg-accent text-foreground")} > {b.icon} </button> ))} </div> {asking && ( <div role="group" aria-labelledby={`${id}-why`} className="grid gap-1.5"> <p id={`${id}-why`} className="text-xs font-medium text-muted-foreground">{msg("message-actions.why", "What was wrong?")}</p> <div className="flex flex-wrap gap-1.5"> {reasons.map((r) => ( <button key={r} type="button" onClick={() => void rate("down", r)} className="h-7 rounded-full border bg-background px-3 text-xs font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none">{r}</button> ))} <button type="button" onClick={() => void rate("down", "")} className="h-7 rounded-full px-3 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50">{msg("message-actions.skip", "Skip")}</button> </div> </div> )} <p aria-live="polite" role={status?.error ? "alert" : undefined} className={cn("min-h-4 text-xs", status?.error ? "font-medium text-destructive" : "text-muted-foreground")}>{status?.text}</p> </div> ) } export { MessageActions, type Feedback, type MessageActionsProps }Update the import paths to match your project setup.
Usage
import { MessageActions } from "@/components/ballmac/message-actions"The full example is in the Code tab above.
Examples
Copy only
"use client"
import { MessageActions } from "@/components/ballmac/message-actions"
export default function MessageActionsMinimal() {
return <MessageActions text="Store credit is in your account now." />
}API reference
| Prop | Type | Default |
|---|---|---|
textThe message text, copied by Copy. Leave it out to hide Copy. | string | — |
onRegenerateAdds Regenerate. Reject and the bar says why. | () => void | Promise<void> | — |
onFeedbackAdds the thumbs. Called with the choice, and for a thumbs down the reason picked. Reject and the choice is taken back. | (value: Feedback, reason?: string) => void | Promise<void> | — |
onBranchAdds Branch, which continues the conversation from here. | () => void | Promise<void> | — |
onShareAdds Share. | () => void | Promise<void> | — |
reasonsThe reasons offered after a thumbs down. | string[] | DEFAULT_REASONS |
defaultFeedbackThe feedback already given, if any. | Feedback | null | null |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Toolbar | role=toolbar with a label; one button is in the tab order and Left, Right, Home and End move between buttons, wrapping at the ends |
| Buttons | Icon buttons with a name and a title; the thumbs have pressed state |
| Results | Copied, the thanks and any error are in one live region; errors are alerts |
| Reasons | A labelled group of buttons after a thumbs down, with Skip |
Use with AI
Each button appears only when you give it something to do: text (Copy), onRegenerate, onFeedback(value, reason?) (the thumbs; a thumbs down asks for a reason from reasons, default five, or Skip), onBranch, onShare. defaultFeedback. Handlers may be async; a rejection is shown as an alert and a thumbs choice is taken back. With nothing to do it renders nothing. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Message Actions (@ballmac/message-actions) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Under every assistant message in a chat or a run
- Collecting quick feedback with a reason on a bad answer
Not for
- Actions on a person's own message; use a menu
- A formal rating form; use a form
Registry JSON: https://ui.ballmac.com/r/message-actions.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
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.
Writing Assistant
Suggested edits shown in place: the words that could change are marked in the text with a different underline per kind, and each suggestion says what changes, why, and what the text becomes. Accept, Dismiss, Accept all, or edit it yourself.
Thinking Loader
A small working pill for AI replies: an eight-ray spinner and a shimmering label that rolls through what the assistant is doing, then settles on a check. Heard once by screen readers; still with reduced motion.
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.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.