AI
Message Actions
The action bar under one chat message — revealed by hover, focus and touch alike, costing zero layout, with copy in either Markdown or plain text, an exclusive thumbs tri-state and speech that stops when the message unmounts.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/message-actions.jsonPrompt
Build a React + TypeScript + Tailwind "MessageActions" component (lucide-react icons, a
shadcn/Radix DropdownMenu for the overflow menu, and the project's cn() helper).
Contract
- export function MessageActions(props): a forwardRef div that renders the message body
(children) and, under it, a role="group" bar of icon buttons.
- markdown: string (required) — the raw source of the message.
plainText?: string — what the reader sees; defaults to
markdownToPlainText(markdown), an exported,
dependency-free stripper that lifts fenced
code out before stripping and puts it back after.
copyFormat?: "markdown" | "plain" — payload of the primary Copy button, default
"markdown"; the other format is always the first
row of the More menu.
actions?: ("copy" | "regenerate" | "feedback" | "branch" | "speak" | "more")[] — order and set.
reveal?: "hover-focus" | "always" — default "hover-focus".
streaming?: boolean — the answer is still being generated.
label?: string — accessible name of the bar, default "Message actions".
onCopy?(text, format) / onCopyError?(error) / onRegenerate?() / onBranch?() / onSpeakChange?(speaking)
feedback? / defaultFeedback? / onFeedbackChange?(value) — "up" | "down" | null, controlled or not.
feedbackReasons?: string[] (default four) / onFeedbackReason?(reason)
moreActions?: { id, label, icon?, onSelect, destructive? }[] — extra rows in the More menu.
- Anything without a real behaviour is not painted: Regenerate needs onRegenerate, Branch
needs onBranch, Read aloud needs window.speechSynthesis. No dead buttons, ever.
Behavior
- Reveal is a union, not a single trigger: hover on the row, :focus-within, an open menu or
an open follow-up row (both "pinned"), a non-mouse pointerdown, and — through
@media (hover: hover) and (pointer: fine) — permanently visible on coarse pointers.
Hover-only would put Copy out of reach of every keyboard user; treat that as a bug class,
not a style choice.
- Reveal changes opacity only. The bar is always in flow, so the content under the message
never moves. Never toggle display or mount/unmount the bar to hide it.
- Every button is its own tab stop inside role="group" (not role="toolbar" + roving
tabindex): a message bar is 4-6 controls and reviewers expect Tab to reach each one.
- Copy: try navigator.clipboard.writeText, then a 1x1 fixed textarea + document.execCommand
fallback for insecure contexts and denied permissions, and only then report failure —
a destructive icon plus onCopyError, never a silent no-op. Success flips to a tick for
~2s and announces; the timer is cleared on unmount. Because the write is async, gate the
result on a mounted ref — otherwise a promise settling after unmount arms a fresh timer
the cleanup has already run past.
- Feedback is one exclusive tri-state (null / up / down) expressed as two aria-pressed
toggles: pressing the selected one clears it. A thumbs-down opens an optional reason row
AFTER the rating is already reported, so it can be dismissed without answering; it never
steals focus and never blocks.
- Regenerate during streaming: aria-disabled="true" plus an early return in the handler.
Never the native disabled attribute — the browser blurs a node the moment it becomes
disabled, dropping focus on <body> mid-press — and no pointer-events:none either, so the
button stays hoverable and announced ("unavailable while generating").
- Read aloud: feature-detect speechSynthesis through useSyncExternalStore (never read
window during render); pressing while speaking stops; cancel() before speaking so only
one message reads at a time; keep a ref to the utterance (Chromium GC hazard); on unmount,
detach the handlers and cancel — but only if this instance was the one speaking.
- Announcements go to one polite sr-only role="status" outside the fading bar, and the copy
result clears itself, so an identical second result is still announced.
Rendering & styling
- Semantic tokens only: text-muted-foreground, hover:bg-accent/hover:text-accent-foreground,
bg-primary/10 for a pressed toggle, text-primary for the copied tick, text-destructive for
the failure, border/bg-card/bg-muted for the surrounding chrome, focus-visible:ring-2
ring-ring. No hex, rgb() or oklch().
- cn() merges the consumer className onto the root; the root is the hover/focus scope
(group/message), the bar is flex-wrap + min-w-0 so it wraps inside a narrow column
instead of overflowing it.
- transition-opacity with motion-reduce:transition-none — under reduced motion the bar
appears instantly and every action still works.
- Icons are aria-hidden; each button carries an aria-label; the group carries `label`.
Customization levers
- Set and order of actions (`actions`), plus extra rows through `moreActions`.
- Default copy payload (`copyFormat`); pass `plainText` from your own Markdown renderer
when a regex stripper is not good enough (tables, footnotes, custom directives).
- `reveal="always"` for a pinned bar (dense inspector UIs, or when the row is not hoverable).
- Reason set (`feedbackReasons`), or `[]` to never ask why.
- Density: swap size-8 for size-7/size-9 and gap-0.5 for gap-1 in one place each.
- Discoverability: wrap each button in a Tooltip if your app already has a TooltipProvider —
the accessible names are already there, a tooltip only adds the visible hint.
- Very long transcripts: if hundreds of messages each adding 6 tab stops is too much,
switch the bar to role="toolbar" with a roving tabindex (one tab stop, arrow keys inside).Concepts
- Reveal is a union, not a trigger — hover,
:focus-within, an explicit pin (open menu / open reason row) and a non-mouse press all raise the same opacity. A hover-only bar is unreachable by keyboard, which is why the reveal is expressed as an OR of four conditions plus a(hover: hover) and (pointer: fine)guard that keeps it permanently visible on touch. - Opacity-only reveal — the bar never leaves the flow, so appearing and disappearing moves nothing under the message.
display:none ⇄ blockwould push the next message by the bar's full height on every mouse move. - Pinned while portalled — a dropdown renders in a portal, so opening it takes focus out of the subtree and
:focus-withingoes false. Without an explicit pinned flag the bar fades out from under its own open menu. - Copy has two truths — the Markdown source and the rendered plain text are different payloads for different destinations; the button copies one and the More menu copies the other, so neither buyer has to fork the component.
- Exclusive tri-state — two
aria-pressedtoggles that clear each other, and clear themselves on a second press. Radios cannot express "no rating" once one is chosen; two independent toggles would let a message be both good and bad. aria-disabledoverdisabled— the native attribute blurs the element the instant it is applied, so a button that disables itself mid-press drops focus on<body>.aria-disabledplus a handler guard keeps the button focusable, announced and explainable.- Speech is a lifecycle, not a call —
speechSynthesisoutlives React. The utterance is held in a ref,cancel()runs before every new read so only one message speaks, and unmount detaches the handlers and cancels — but only if this instance was the one speaking.
Chat Composer
A message composer that owns the send⇄stop run — clears the draft on send, aborts on stop, restores it on failure, and gates send on attachment uploads.
Reasoning Block
A collapsible thinking panel whose open/closed state is driven by the stream's lifecycle — and permanently yields to the reader the moment they touch it.