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…

"use client"

import * as React from "react"
import {
  Check,
  Copy,
  CopyX,
  GitBranch,
  MoreHorizontal,
  RotateCcw,
  Square,
  ThumbsDown,
  ThumbsUp,
  Volume2,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/message-actions.json

Prompt

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 ⇄ block would 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-within goes 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-pressed toggles 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-disabled over disabled — the native attribute blurs the element the instant it is applied, so a button that disables itself mid-press drops focus on <body>. aria-disabled plus a handler guard keeps the button focusable, announced and explainable.
  • Speech is a lifecycle, not a callspeechSynthesis outlives 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.

On This Page