Chat Message Actions
Hold one chat bubble and its quick reactions plus that message's actions come up against it — as an anchored menu, a compact toolbar or a sheet from the bottom edge.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chat-message-actions.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ChatMessageActions" component (lucide-react
for Check / MoreHorizontal / X, a cn() class merger, nothing else — the hold, the
placement maths and the safe area are the whole point and must be self-contained).
It wraps ONE row of a chat transcript and turns a press-and-hold on the bubble into
a quick-reaction strip plus that message's actions. It is not a generic context
menu: the reaction strip is always the first row of the panel, and the panel is
bound to one message by name.
Contract
- "use client". forwardRef<HTMLDivElement, ChatMessageActionsProps> extending
React.HTMLAttributes<HTMLDivElement>; the rest props and the ref go to the root,
`className` merges onto it, `contentClassName` onto the pressable surface.
- Data:
- ChatMessageReaction = { emoji, label } — `label` is the accessible name, so a
screen reader is never handed a bare codepoint.
- ChatMessageAction = { key, label, description?, icon?, tone?: "default" |
"destructive", disabled?, disabledReason?, keepOpen?, onSelect? }.
- Props:
- children — the bubble. It stays interactive; links inside it still take a tap.
- label: string — what this message is ("Ada at 9:41"). Names the trigger
("Actions for <label>"), the panel ("Message actions: <label>") and every
announcement.
- actions?: ChatMessageAction[] — default []. An empty list is legal: the panel
is then a bare reaction strip.
- reactions?: ChatMessageReaction[] — default six (thumbs up, heart, laugh,
surprised, sad, thanks). `[]` means this message cannot be reacted to.
- reaction / defaultReaction / onReactionChange(emoji | null, source) —
controlled and uncontrolled both supported. One reaction per person: picking
the one that is already yours reports null (removed).
- open / defaultOpen / onOpenChange(open, source) — same pair. `source` is
"hold" | "button" | "keyboard" | "dismiss", so a consumer can log the paths
apart.
- variant?: "menu" (default) | "compact" | "sheet" — three genuinely different
presentations, see below.
- side?: "incoming" (default) | "outgoing" — which end of the row the bubble,
the badge and the anchored panel hang from.
- holdDuration?: number — default 450, clamped to 150..2000 (a NaN hold would
otherwise fire on contact).
- disabled? + disabledReason? — refuses the hold, the trigger and the panel.
- showTrigger?: boolean — default true. `false` takes the ⋯ button out of flow
and makes it invisible and pointer-transparent, but it stays focusable and
announced, and reappears on focus-visible.
- selecting? / selected? / defaultSelected? / onSelectedChange? — thread-level
multi-select. `selecting` is owned by the consumer (the Select action turns it
on for every row at once).
- haptics?: boolean — default true, one 8ms navigator.vibrate as the panel rises.
- labels?: Partial<...> — ten strings (trigger, panel, reactions, removeReaction,
reacted, reactionRemoved, select, close, refused, whileSelecting) for i18n.
Behavior
- The hold: pointerdown records { pointerId, startX, startY, target, fired } in a
ref and starts a timer. Pointer Events only — never a mouse pair and a touch pair.
Drift over 10px on either axis before the timer lands drops the gesture FOR GOOD,
so a flick that started on a bubble stays a scroll; `touch-action: pan-y` on the
surface is what lets the browser own that scroll without a preventDefault on a
passive listener. When the timer lands it sets `fired` synchronously (the one-shot
guarantee is a ref written and read in the handler, never state), calls
setPointerCapture on the surface that started the gesture — without it the release
lands on the panel that just appeared, this row never hears pointerup, and the
stale record blocks every later hold — ticks the vibrator, arms a 350ms click
suppression window so the link under the finger does not also fire, and opens.
- contextmenu is prevented on the surface (Android raises its own menu mid-hold,
iOS pops the callout, and -webkit-touch-callout:none finishes the job); a desktop
right click with no hold in flight is routed into the same panel instead of being
swallowed.
- While open: a fixed scrim drops the rest of the transcript back — no blur, no
clone, no portal. The root takes a z-index only while open and the row sits above
the scrim inside it, so the message being acted on stays crisp and in place. The
panel is an absolutely positioned child, so it scrolls with the transcript instead
of drifting off its bubble. A press on the lit row dismisses, exactly like a press
on the scrim.
- Placement (menu / compact): measured against window.visualViewport, not the layout
viewport — with the software keyboard up the layout viewport still claims the full
height and a panel placed against it resolves behind the keyboard. Above wins ties
(below the bubble is where the hand is); the winning side's free space becomes the
panel's max-height, so a long action list scrolls inside the panel instead of being
clipped by the transcript. Re-measured on window resize, capture-phase scroll and
both visualViewport events, all rAF-throttled and all removed on close.
- Reactions: exactly one per person. Picking a new one replaces it, picking the
current one removes it, and either way the panel closes. The chosen emoji is
rendered as a small badge hanging off the bubble's bottom corner on `side`.
- Actions: choosing one closes the panel FIRST and calls onSelect after, because the
handler may unmount half the thread (entering selection mode, deleting the row) and
focus has to be home before that happens. `keepOpen` items (a Pin toggle) leave it
up. A `disabled` item is never natively disabled: it keeps its place in the ring
and announces `disabledReason` when chosen.
- Selection mode: while `selecting`, every row grows a 44px checkbox
(role="checkbox" + aria-checked), the bubble itself becomes a tap target for the
same toggle (aria-hidden and tabIndex -1 — the checkbox is the real control and
announcing it twice is worse), the hold stands down and the panel cannot open. The
⋯ button is NOT removed: it reports aria-disabled and announces why, so nobody
loses focus to <body>.
- Keyboard: the panel is a two-dimensional roving menu (role="menu", one tab stop,
real focus rather than aria-activedescendant, so Enter and Space need no handler).
Left/Right walk a row and wrap; Up/Down step between rows keeping the column;
Home/End jump to the ends. Escape closes and stops propagation, so one Escape
closes one layer. Tab also closes — but it leaves via the trigger, so the next Tab
continues from the row instead of from the document body. Opening lands on the
reaction that is already yours, so removing it is one keystroke.
- Empty kit = refusal: with no reactions AND no actions there is no hold, no trigger
and no aria-haspopup — never an empty popup.
- Announcements: a polite role="status" live region carries the pick, the removal,
a refused row and a refused item; it self-clears after 2.4s so the same outcome can
be announced twice.
- Cleanup: the hold timer, the announcement timer, the rAF and all four placement
listeners are cancelled on unmount and on every dependency change; pointer capture
is released on the node that took it.
Rendering & styling
- Semantic tokens only, zero colour literals: bg-background / bg-card / bg-muted /
bg-popover + text-popover-foreground / border / ring-ring / text-muted-foreground /
text-destructive. The highest-priority surfaces INVERT rather than take a colour —
the reaction that is already yours and the checked checkbox are `bg-foreground
text-background`. The scrim is `bg-background/75`.
- Radii ladder 16/12/8: panel and sheet cards rounded-2xl, action rows rounded-lg,
reaction and icon buttons rounded-full. Type stays small and tight: action label
13px/500, description 11px, the sheet's message name 11px.
- Three variants: "menu" = strip over a labelled vertical list, min-w-52, anchored to
the bubble's side; "compact" = strip over a row of icon-only 44px buttons in one
pill; "sheet" = fixed to the bottom edge, a header naming the message plus a close
button, then the strip and the list as separate cards, capped at 80dvh.
- Touch: every target is 44px in its hit area (size-11), nothing is hover-only, and
every gesture has a button or a key that does the same thing.
- Safe area: the sheet pads with
max(var(--safe-area-inset-<edge>, env(safe-area-inset-<edge>, 0px)), 12px) on
bottom / left / right, so it clears the home indicator and a landscape sensor
housing; the custom property lets a device frame or a test simulate insets on
hardware that reports 0. The anchored panel caps its width at
min(20rem, calc(100vw - both insets - 1.5rem)).
- Motion: animate-in fade/zoom for the anchored panel, slide-from-bottom for the
sheet, a 0.97 press-in scale while charging — all with motion-reduce:animate-none
/ motion-reduce:scale-100, and under reduced motion the press-in is replaced by a
ring so the feedback survives with the decoration off.
Customization levers
- Timing and physics: holdDuration (450ms feels right on a phone, 250 on a tablet),
the 10px drift tolerance, the 350ms click suppression, the 8ms haptic tick — all
single constants at the top.
- Which sub-blocks exist: pass actions={[]} for a reactions-only kit,
reactions={[]} for actions only, showTrigger={false} to hide the ⋯ column (it
stays focusable), haptics={false} to drop the vibration.
- Layout density: swap min-w-52 / size-11 / the 13px-11px type pair for a denser
thread; drop `description` to get single-line rows; move the sheet's close button
into a detached Cancel block if you want the OS look.
- Placement policy: flip the `above >= below` tie-break if your composer sits at the
top, or pin the panel to one side by hardcoding the placement branch.
- Colour: everything hangs off popover / muted / foreground / destructive — restyle
by moving tokens, never by adding hex. Reaction tallies belong to a separate
reaction bar; pass reaction={null} and render your own if you tally per emoji.
- Known constraint to keep in mind when adapting: the scrim and the sheet are
`position: fixed` without a portal. Inside an ancestor with a transform or filter
they would anchor to that ancestor — wrap them in createPortal if your transcript
lives inside a transformed page-transition shell.Concepts
- Hold-to-raise, drift-to-scroll — the press only becomes a menu if the finger stays inside 10px until the timer lands; the first decisive movement gives the gesture back to the transcript for good, so a flick that started on a bubble is still a flick.
- Scrim without a lift — the rest of the thread drops behind a scrim while the held row keeps its place and its pixels. Nothing is cloned into a portal and nothing is blurred, which is what makes the panel an ordinary child that scrolls with its message.
- Visual-viewport placement — above or below is decided from
visualViewport, and the winning side's free space becomes the panel's max-height. The layout viewport lies while the software keyboard is up, and a menu placed against it resolves behind the keys. - One reaction per person — the strip is a picker, not a tally:
menuitemradiochips, picking the current one removes it, and the result rides as a badge on the bubble's corner. Per-emoji counts are a different component. - Select outlives the panel — one action deliberately changes the whole thread instead of the message: checkboxes appear on every row, the bubble becomes a toggle, and the hold stands down until selection ends.
- Twin path, and focus that lands — every message keeps a real ⋯ button (invisible but focusable when hidden), the panel is a two-dimensional roving menu, and every close path — key, tap, scrim, or the consumer pulling the panel away — puts focus back on that button rather than on the document body.
Reasoning Timeline
A thumb-scrubbed reasoning chain for phones — the running node is called out on an inverted card, resolved nodes fold into a count, and the window never becomes a nested scroller.
Hold To Talk
A press-and-hold voice-message button: the strip takes over the composer with a live waveform, sliding up locks the recording hands-free, sliding toward the edge discards it, releasing sends it — and every one of those has a key or a button.