---
title: ChatMessage
description: A single chat bubble that renders differently based on the sender — left-aligned for the assistant, right-aligned with a background for the user.
---

```tsx
'use client';

import { ChatMessage } from '@/ui/ChatMessage';

export function ChatMessageDemo() {
  return (
    <div className="w-[380px] flex flex-col gap-4">
      <ChatMessage sender="assistant">
        Hi. Ask me anything about Club Med. I&apos;m here to help you find the right information
        quickly.
      </ChatMessage>
      <ChatMessage sender="user">
        My son is 14 years old, and I would like to find activities for him during our stay. What do
        you suggest?
      </ChatMessage>
    </div>
  );
}
```

## Installation

Using the CLI:

```bash
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/chat-message.json
```

Or manually, copy the following code into `/ui/ChatMessage.tsx`:

```tsx
'use client';

import {
  memo,
  type HTMLAttributes,
  type PropsWithChildren,
  useEffect,
  useRef,
  useState,
} from 'react';
import { measureLineStats, prepareWithSegments } from '@chenglou/pretext';
import { ChatMessageActions, type ChatMessageLabels } from './ChatMessageActions';
import { ChatMessageEditor } from './ChatMessageEditor';
import { twMerge } from '../helpers/twMerge';

export type ChatMessageSender = 'assistant' | 'user';

export interface ChatMessageProps extends HTMLAttributes<HTMLDivElement> {
  sender: ChatMessageSender;
  /** Actions of a user message, shown when it is hovered or focused. Ignored for assistant messages. */
  onReplay?: () => void;
  /**
   * Enables the in-place edition of a text message: the bubble turns into an editor, and this is
   * called with the new text when the user sends it. Ignored when the content is not a string.
   */
  onEdit?: (content: string) => void;
  /** May be async: a confirmation is shown once the returned promise is resolved */
  onCopy?: () => void | Promise<void>;
  labels?: Partial<ChatMessageLabels>;
}

const FONT = '16px Inter';
const MAX_WIDTH_RATIO = 0.7;
const H_PADDING = 48;

function useShrinkWidth(text: string, containerRef: React.RefObject<HTMLDivElement | null>) {
  const [maxWidth, setMaxWidth] = useState<number | undefined>(undefined);

  useEffect(() => {
    const container = containerRef.current;
    if (!container || !text) return;

    try {
      const availableWidth = container.offsetWidth * MAX_WIDTH_RATIO;
      const prepared = prepareWithSegments(text, FONT);
      const { maxLineWidth } = measureLineStats(prepared, availableWidth - H_PADDING);
      setMaxWidth(maxLineWidth + H_PADDING);
    } catch {
      // Canvas or Intl.Segmenter unavailable (SSR / test env) — fall back to CSS cap
    }
    // containerRef is a stable ref object — its identity never changes
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [text]);

  return maxWidth;
}

export const ChatMessage = memo(function ChatMessage({
  sender,
  children,
  className,
  onReplay,
  onEdit,
  onCopy,
  labels,
  ...props
}: PropsWithChildren<ChatMessageProps>) {
  const isUser = sender === 'user';
  const containerRef = useRef<HTMLDivElement>(null);

  // Rich content (e.g. rendered Markdown) holds block elements, which a <p> cannot contain.
  const Bubble = typeof children === 'string' ? 'p' : 'div';
  const [isEditing, setIsEditing] = useState(false);
  const canEdit = !!onEdit && typeof children === 'string';
  const hasActions = isUser && !!(onReplay || canEdit || onCopy);
  const text = isUser && typeof children === 'string' ? children : '';
  const shrinkMaxWidth = useShrinkWidth(text, containerRef);

  const bubble = (
    <Bubble
      style={shrinkMaxWidth !== undefined ? { maxWidth: shrinkMaxWidth } : undefined}
      className={twMerge(
        'text-b3',
        isUser ? 'bg-lightSand max-w-[70%] rounded-32 px-24 py-12 text-black' : 'text-black',
        hasActions && 'max-w-full',
      )}
    >
      {children}
    </Bubble>
  );

  return (
    <div
      ref={containerRef}
      role="article"
      aria-label={isUser ? 'Your message' : 'Assistant message'}
      {...props}
      className={twMerge('flex w-full px-20', isUser ? 'justify-end' : 'justify-start', className)}
    >
      {isEditing && typeof children === 'string' ? (
        <ChatMessageEditor
          value={children}
          labels={labels}
          onCancel={() => setIsEditing(false)}
          onSubmit={(content) => {
            setIsEditing(false);
            onEdit?.(content);
          }}
        />
      ) : hasActions ? (
        <div className="group/message flex max-w-[70%] flex-col items-end gap-4">
          {bubble}
          <ChatMessageActions
            onReplay={onReplay}
            onEdit={canEdit ? () => setIsEditing(true) : undefined}
            onCopy={onCopy}
            labels={labels}
          />
        </div>
      ) : (
        bubble
      )}
    </div>
  );
});
```

## Usage

```tsx showLineNumbers
import { ChatMessage } from '@/ui/ChatMessage';

export function Component() {
  return (
    <>
      <ChatMessage sender="assistant">Hi. Ask me anything about Club Med.</ChatMessage>
      <ChatMessage sender="user">What activities are available for children?</ChatMessage>
    </>
  );
}
```

## Actions

Give a user message `onReplay`, `onEdit` and/or `onCopy` to show its actions under the bubble. They
are revealed when the bubble is hovered or focused (always visible on devices without hover), and a
button is only rendered when its callback is provided. They are ignored for assistant messages.

```tsx
'use client';

import { useState } from 'react';
import { ChatMessage } from '@/ui/ChatMessage';

export function ChatMessageWithActionsDemo() {
  const [content, setContent] = useState('Can you suggest activities for a 14 year old?');

  return (
    <div className="w-[380px] flex flex-col gap-4">
      <ChatMessage sender="assistant">
        Hi. Ask me anything about Club Med. I&apos;m here to help you find the right information
        quickly.
      </ChatMessage>
      <ChatMessage
        sender="user"
        onReplay={() => {}}
        onEdit={setContent}
        onCopy={() => navigator.clipboard.writeText(content)}
      >
        {content}
      </ChatMessage>
    </div>
  );
}
```

### Copy confirmation

Once `onCopy` succeeded, the copy icon is replaced by a check for 2 seconds. `onCopy` can be async:
the confirmation appears when the promise resolves, and nothing appears when it rejects.

### Edit in place

`onEdit` enables the in-place edition of a **text** message: the edit button turns the bubble into
an editor (see [ChatMessageEditor](/docs/components/chat-message-editor)) with **Cancel** and
**Send** buttons. `onEdit` is called with the new text when it is sent — it is up to you to replace
the message, and to drop or regenerate the answers that followed it.

The edit action is not offered when `children` is not a string (for instance rendered Markdown).

### Translate the labels

`labels` translates the accessible names, the tooltips and the editor buttons. Every entry is
optional.

```tsx showLineNumbers
<ChatMessage
  sender="user"
  onReplay={() => {}}
  onEdit={() => {}}
  onCopy={() => {}}
  labels={{
    replay: 'Rejouer',
    edit: 'Modifier',
    copy: 'Copier',
    copied: 'Copié',
    cancel: 'Annuler',
    send: 'Envoyer',
  }}
>
  Bonjour
</ChatMessage>
```

## Rich content

`children` can be any node, for instance rendered Markdown. The bubble is then a `div`, which can
hold block elements (lists, code blocks), instead of a `p`.

## API Reference

### ChatMessage

| Prop        | Type                                | Default     | Description                                                              |
| ----------- | ----------------------------------- | ----------- | ------------------------------------------------------------------------ |
| `sender`    | `"assistant" \| "user"`             | —           | **Required.** Determines alignment and styling                           |
| `children`  | `ReactNode`                         | —           | Message content. Text is rendered in a `p`, any other node in a `div`    |
| `onReplay`  | `() => void`                        | `undefined` | User messages only. Renders the replay action                            |
| `onEdit`    | `(content: string) => void`         | `undefined` | User text messages only. Enables the in-place edition, called with the new text |
| `onCopy`    | `() => void \| Promise<void>`       | `undefined` | User messages only. Renders the copy action, confirmed with a check once it succeeded |
| `labels`    | `Partial<ChatMessageLabels>`        | English     | Labels of the actions and of the editor. See [ChatMessageActions](/docs/components/chat-message-actions#chatmessagelabels) |
| `className` | `string`                            | `undefined` | Additional CSS classes                                                   |

Inherits all `HTMLDivElement` attributes.

## Notes

- Assistant messages are left-aligned with no background.
- User messages are right-aligned with a `pearl` background pill.
- The actions and the editor are built with [ChatMessageActions](/docs/components/chat-message-actions) and [ChatMessageEditor](/docs/components/chat-message-editor).
- Both use `role="article"` with a descriptive `aria-label` for screen reader support.
- Used inside `ChatWindow` as part of the message list.
