Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, MoreHorizontal, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** How long the finger has to stay put before the panel is raised. */
const DEFAULT_HOLD_MS = 450
const MIN_HOLD_MS = 150
const MAX_HOLD_MS = 2000
/** Drift (px) that hands the press back to the transcript's own scrolling. */
const MOVE_TOLERANCE_PX = 10
/** A hold that raised the panel swallows exactly one following click. */
const CLICK_SUPPRESS_MS = 350

Installation

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

Prompt

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: menuitemradio chips, 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.

On This Page