---
title: ChatBody
description: The scrollable message log of a chat panel — messages, typing indicator, CSAT prompt, and price quotes.
---

```tsx
'use client';

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

export function ChatBodyDemo() {
  const [messages, setMessages] = useState<ChatMessage[]>([
    {
      id: '1',
      sender: 'assistant',
      content:
        "Hi. Ask me anything about Club Med. I'm here to help you find the right information quickly.",
    },
    {
      id: '2',
      sender: 'user',
      content:
        'My son is 14 years old, and I would like to find activities for him during our stay.',
    },
  ]);
  const [inputValue, setInputValue] = useState('');
  const [isTyping, setIsTyping] = useState(false);

  function handleSubmit() {
    if (!inputValue.trim()) return;
    const userMsg: ChatMessage = {
      id: Date.now().toString(),
      sender: 'user',
      content: inputValue,
    };
    setMessages((prev) => [...prev, userMsg]);
    setInputValue('');
    setIsTyping(true);
    setTimeout(() => {
      setIsTyping(false);
      setMessages((prev) => [
        ...prev,
        {
          id: (Date.now() + 1).toString(),
          sender: 'assistant',
          content: 'Let me look into that for you…',
        },
      ]);
    }, 2000);
  }

  return (
    <div className="chat-border bg-white flex flex-col w-380 h-660 rounded-32 overflow-hidden">
      <ChatBody messages={messages} isTyping={isTyping} />
      <div className="shrink-0 px-8 pb-8 pt-12">
        <ChatInput value={inputValue} onValueChange={setInputValue} onSubmit={handleSubmit} />
      </div>
    </div>
  );
}
```

## Installation

Using the CLI:

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

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

```tsx
import { ChatMessage, type ChatMessageSender } from './ChatMessage';
import type { ChatMessageLabels } from './ChatMessageActions';
import { ChatTypingIndicator } from './ChatTypingIndicator';
import type { ChatWindowCsatPromptLabels } from './ChatWindowCsatPrompt';
import { ChatWindowCsatPrompt } from './ChatWindowCsatPrompt';
import {
  ChatWindowCsatThankYou,
  type ChatWindowCsatThankYouLabels,
} from './ChatWindowCsatThankYou';
import { ChatWindowQuote, type ChatWindowQuoteItem } from './ChatWindowQuote';
import {
  forwardRef,
  type HTMLAttributes,
  type ReactNode,
  type Ref,
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
} from 'react';
import { twMerge } from '../helpers/twMerge';

export interface ChatMessage {
  id: string;
  sender: ChatMessageSender;
  content: ReactNode;
}

export interface ChatBodyProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
  greeting?: string;
  messages: ChatMessage[];
  isTyping?: boolean;
  /** Actions of the user messages (replay, edit, copy), shown when a message is hovered or focused */
  onMessageReplay?: (message: ChatMessage) => void;
  onMessageEdit?: (message: ChatMessage, content: string) => void;
  onMessageCopy?: (message: ChatMessage) => void | Promise<void>;
  messageActionLabels?: Partial<ChatMessageLabels>;
  onFeedbackRate?: (rating: number) => void;
  csatLabels?: ChatWindowCsatPromptLabels;
  csatRenderRatingLabel?: (rating: number) => ReactNode;
  csatThankYouLabels?: ChatWindowCsatThankYouLabels;
  onQuoteCtaClick?: (quote: ChatWindowQuoteItem) => void;
}

export interface ChatHandle {
  askForFeedback: () => void;
  showQuotes: (quotes: ChatWindowQuoteItem[]) => void;
}

function useChat(
  { messages, isTyping, csatRenderRatingLabel, onFeedbackRate, csatLabels }: ChatBodyProps,
  ref: Ref<ChatHandle>,
) {
  const logRef = useRef<HTMLDivElement>(null);
  const [showCsat, setShowCsat] = useState(false);
  const [csatRatingMessage, setCsatRatingMessage] = useState<string | null>(null);
  const [quoteGroups, setQuoteGroups] = useState<ChatWindowQuoteItem[][]>([]);

  useImperativeHandle(ref, () => ({
    askForFeedback() {
      setShowCsat(true);
    },
    showQuotes(quotes: ChatWindowQuoteItem[]) {
      setQuoteGroups((prev) => [...prev, quotes]);
    },
  }));

  useEffect(() => {
    const el = logRef.current;
    if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
  }, [messages, isTyping, showCsat, csatRatingMessage, quoteGroups]);

  function handleRate(rating: number) {
    const label = csatRenderRatingLabel ? String(csatRenderRatingLabel(rating)) : String(rating);
    setCsatRatingMessage(label);
    onFeedbackRate?.(rating);
  }

  const csatReady = showCsat && csatLabels && csatRenderRatingLabel;

  return { logRef, csatRatingMessage, quoteGroups, handleRate, csatReady };
}

export const ChatBody = forwardRef<ChatHandle, ChatBodyProps>(function Chat(props, ref) {
  const { logRef, csatRatingMessage, quoteGroups, handleRate, csatReady } = useChat(props, ref);

  const {
    greeting,
    messages,
    isTyping = false,
    onMessageReplay,
    onMessageEdit,
    onMessageCopy,
    messageActionLabels,
    csatLabels,
    csatRenderRatingLabel,
    csatThankYouLabels,
    onQuoteCtaClick,
    className,
    ...extraProps
  } = props;

  return (
    <div
      {...extraProps}
      ref={logRef}
      role="log"
      aria-label="Chat messages"
      aria-live="polite"
      className={twMerge(
        'chat-message-log flex flex-col flex-1 gap-20 overflow-y-auto py-20',
        className,
      )}
    >
      {greeting ? (
        <div className="px-20">
          <p className="text-b3 text-black">{greeting}</p>
        </div>
      ) : null}

      {messages.map((msg) => (
        <ChatMessage
          key={msg.id}
          sender={msg.sender}
          onReplay={onMessageReplay && (() => onMessageReplay(msg))}
          onEdit={onMessageEdit && ((content) => onMessageEdit(msg, content))}
          onCopy={onMessageCopy && (() => onMessageCopy(msg))}
          labels={messageActionLabels}
        >
          {msg.content}
        </ChatMessage>
      ))}

      {isTyping && (
        <div className="px-12">
          <ChatTypingIndicator />
        </div>
      )}

      {csatReady && (
        <ChatWindowCsatPrompt
          onRate={handleRate}
          labels={csatLabels!}
          renderRatingLabel={csatRenderRatingLabel!}
        />
      )}

      {csatRatingMessage && <ChatMessage sender="user">{csatRatingMessage}</ChatMessage>}

      {csatRatingMessage && csatThankYouLabels && (
        <ChatWindowCsatThankYou labels={csatThankYouLabels} />
      )}

      {quoteGroups.map((quotes, idx) => (
        <ChatWindowQuote key={idx} quotes={quotes} onCtaClick={onQuoteCtaClick} />
      ))}
    </div>
  );
});
```

## Usage

`ChatBody` renders the message log only — pair it with `ChatInput` for a working chat panel.

```tsx showLineNumbers
import { ChatBody } from '@/ui/ChatBody';
import type { ChatMessage } from '@/ui/ChatBody';
import { ChatInput } from '@/ui/ChatInput';
import { useState } from 'react';

export function Component() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [inputValue, setInputValue] = useState('');
  const [isTyping, setIsTyping] = useState(false);

  function handleSubmit() {
    if (!inputValue.trim()) return;
    setMessages((prev) => [
      ...prev,
      { id: Date.now().toString(), sender: 'user', content: inputValue },
    ]);
    setInputValue('');
    // Trigger your AI call here, then set isTyping accordingly
  }

  return (
    <div className="chat-border bg-white flex flex-col w-380 h-660 rounded-32 overflow-hidden">
      <ChatBody messages={messages} isTyping={isTyping} />
      <div className="shrink-0 px-8 pb-8 pt-12">
        <ChatInput value={inputValue} onValueChange={setInputValue} onSubmit={handleSubmit} />
      </div>
    </div>
  );
}
```

`ChatBody` is typically composed alongside `ChatHeader` and `ChatInput` — see the
[ChatWindow](/docs/components/chat-window#composable-recommended) docs for the full
composable pattern.

## Message actions

Give `ChatBody` `onMessageReplay`, `onMessageEdit` and/or `onMessageCopy` to show the
[actions](/docs/components/chat-message#actions) of its **user** messages. Each callback receives the
message concerned, and `onMessageEdit` also receives the new text once the user sent the in-place
edition (only text messages can be edited).

```tsx showLineNumbers
<ChatBody
  messages={messages}
  onMessageReplay={(message) => resend(message)}
  onMessageEdit={(message, content) => resendEdited(message, content)}
  onMessageCopy={(message) => navigator.clipboard.writeText(String(message.content))}
  messageActionLabels={{ copy: 'Copier', copied: 'Copié' }}
/>
```

It is up to you to update the conversation: a typical edition replaces the message and removes the
ones after it before generating a new answer.

## Ask for Feedback (CSAT)

Call `chatRef.current.askForFeedback()` at any point to inject the CSAT flow into the message log.
When the user picks a star rating, three things happen in sequence:

1. A **user-side message** appears in the log with the rating label (e.g. `"4 stars — Satisfied"`).
2. The star buttons lock — the prompt stays visible but cannot be re-submitted.
3. A **thank-you note** from the assistant is appended below.
4. `onFeedbackRate` is called with the chosen value (`1`–`5`).

```tsx showLineNumbers
import { useRef } from 'react';
import { ChatBody } from '@/ui/ChatBody';
import type { ChatHandle } from '@/ui/ChatBody';
import type { ChatWindowCsatPromptLabels, ChatWindowCsatThankYouLabels } from '@/ui/ChatWindow';

const csatLabels: ChatWindowCsatPromptLabels = {
  intro: 'Your opinion matters! Tell us what you think.',
  question: 'From 1 to 5, how satisfied are you with the service you received,',
  questionNote: '1 star being "Very dissatisfied" and 5 stars "Very satisfied".',
  groupAriaLabel: 'Rate your experience from 1 to 5 stars',
  starLabels: ['Very dissatisfied', 'Dissatisfied', 'Neutral', 'Satisfied', 'Very satisfied'],
};

const csatThankYouLabels: ChatWindowCsatThankYouLabels = {
  title: 'Thank you for your feedback!',
  body: 'Your opinion means a lot to us.',
};

function csatRenderRatingLabel(rating: number) {
  return `${rating} star${rating > 1 ? 's' : ''} — ${csatLabels.starLabels[rating - 1]}`;
}

export function Component() {
  const chatRef = useRef<ChatHandle>(null);

  return (
    <ChatBody
      ref={chatRef}
      messages={messages}
      csatLabels={csatLabels}
      csatRenderRatingLabel={csatRenderRatingLabel}
      csatThankYouLabels={csatThankYouLabels}
      onFeedbackRate={(rating) => console.log('Rated:', rating)}
    />
  );
}
```

## Show Price Quotes

Call `chatRef.current.showQuotes(quotes)` to inject a quote block into the message log.
Each call **appends** a new block below any previous ones, so multiple rounds of quotes stack naturally in the conversation.

```tsx showLineNumbers
import { useRef } from 'react';
import { ChatBody } from '@/ui/ChatBody';
import type { ChatHandle } from '@/ui/ChatBody';
import type { ChatWindowQuoteItem } from '@/ui/ChatWindowQuote';

export function Component() {
  const chatRef = useRef<ChatHandle>(null);

  return (
    <ChatBody
      ref={chatRef}
      messages={messages}
      onQuoteCtaClick={(quote: ChatWindowQuoteItem) => console.log('Selected:', quote.resortName)}
    />
  );
}
```

## API Reference

### ChatBody

| Prop                    | Type                                    | Default      | Description                                                          |
| ----------------------- | ---------------------------------------- | ------------ | ---------------------------------------------------------------------- |
| `messages`              | `ChatMessage[]`                          | —            | **Required.** Ordered list of messages to display                     |
| `greeting`              | `string`                                 | `undefined`  | Optional static greeting text shown above the message list             |
| `isTyping`               | `boolean`                                | `false`      | When true, shows the animated typing indicator                         |
| `onMessageReplay`       | `(message: ChatMessage) => void`         | `undefined`  | Renders the replay action on the user messages                         |
| `onMessageEdit`         | `(message: ChatMessage, content: string) => void` | `undefined` | Enables the in-place edition of the user text messages, called with the new text |
| `onMessageCopy`         | `(message: ChatMessage) => void \| Promise<void>` | `undefined` | Renders the copy action on the user messages. A check confirms it once it succeeded |
| `messageActionLabels`   | `Partial<ChatMessageLabels>`             | English      | Labels of the actions and of the editor                                |
| `onFeedbackRate`        | `(rating: number) => void`               | `undefined`  | Called with the star rating (1–5) when the user submits feedback       |
| `csatLabels`            | `ChatWindowCsatPromptLabels`             | `undefined`  | Labels for the rating prompt. Required to enable the CSAT flow         |
| `csatRenderRatingLabel` | `(rating: number) => ReactNode`          | `undefined`  | Renders the caption shown under the active star. Required with CSAT    |
| `csatThankYouLabels`    | `ChatWindowCsatThankYouLabels`           | `undefined`  | Labels for the thank-you note shown after submission                   |
| `onQuoteCtaClick`       | `(quote: ChatWindowQuoteItem) => void`   | `undefined`  | Called with the selected quote when a "See prices" CTA is tapped       |
| `className`             | `string`                                 | `undefined`  | Additional CSS classes on the message-log element                      |

Inherits all remaining `HTMLDivElement` attributes.

### ChatHandle

Imperative handle exposed via `ref`. Use `forwardRef` or `useRef<ChatHandle>`.

| Method            | Signature                                 | Description                                                                                 |
| ----------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- |
| `askForFeedback`  | `() => void`                               | Displays the CSAT star-rating prompt in the message log                                       |
| `showQuotes`      | `(quotes: ChatWindowQuoteItem[]) => void`  | Appends a quote block to the message log. Each call stacks a new block below the previous.    |

### ChatMessage

```tsx
interface ChatMessage {
  id: string;
  sender: 'assistant' | 'user';
  content: ReactNode;
}
```

## Notes

- The component is **entirely controlled from the outside** — `messages` and `isTyping` are managed by the parent.
- Renders with `role="log"` and `aria-live="polite"` so screen readers announce new messages automatically.
- Does **not** render an input bar — pair it with `ChatInput` (see the Usage example above).
- `askForFeedback()` is idempotent per mount — calling it more than once only shows the prompt once.
- The CSAT flow only activates when all three `csat*` props are provided. Omitting any of them silently disables the flow.
- The rating message appended to the log is purely internal to `ChatBody` and does not touch the parent-controlled `messages` array.
