---
title: Message
description: A role-aware container for one turn, with chip-segmented text and selection hooks.
source: message
---

```tsx title="primitives/message/demos/basic.tsx"
"use client";

import { Message } from "@intentface/chat/message";
import { type ComponentProps, useState } from "react";

// Message.Root stamps data-role / data-last / data-error and imposes no layout;
// the bubble, alignment, and actions are all yours.
const MESSAGES = [
  { id: "q", role: "user", text: "How do I center a div?" },
  {
    id: "a",
    role: "assistant",
    text: "Use flexbox on the parent: display: flex, then justify-content: center and align-items: center.",
  },
];

export const Basic = () => (
  <div className="flex w-full max-w-xl flex-col gap-4">
    {MESSAGES.map((message, index) => (
      <Message.Root
        key={message.id}
        role={message.role}
        isLast={index === MESSAGES.length - 1}
        className="group flex w-full flex-col gap-1 data-[role=user]:items-end"
      >
        {/* data-role sits on Root, so the bubble reads it through the group. */}
        <Message.Text className="text-sm leading-[1.7] text-[#1a1a1a] group-data-[role=user]:min-h-9 group-data-[role=user]:max-w-[80%] group-data-[role=user]:rounded-2xl group-data-[role=user]:border group-data-[role=user]:border-[#f0f0f0] group-data-[role=user]:bg-white group-data-[role=user]:px-3 group-data-[role=user]:py-1.5 group-data-[role=user]:shadow-xs dark:text-[#fcfcfc] dark:group-data-[role=user]:border-[#262626] dark:group-data-[role=user]:bg-[#181818]">
          {message.text}
        </Message.Text>
        {message.role === "assistant" && <CopyButton value={message.text} />}
      </Message.Root>
    ))}
  </div>
);

const CopyButton = ({ value }: { value: string }) => {
  const [copied, setCopied] = useState(false);

  const handleCopy = async () => {
    await navigator.clipboard.writeText(value);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <button
      type="button"
      onClick={handleCopy}
      aria-label="Copy message"
      className="flex size-7 cursor-pointer items-center justify-center rounded-md text-[#949494] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] dark:text-[#6f6f6f] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc]"
    >
      {copied ? <CheckIcon /> : <CopyIcon />}
    </button>
  );
};

const CopyIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="14"
    height="14"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
    <path d="M10.5 3.5v-.5a1.5 1.5 0 0 0-1.5-1.5H3a1.5 1.5 0 0 0-1.5 1.5v6A1.5 1.5 0 0 0 3 10.5h.5" />
  </svg>
);

const CheckIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="14"
    height="14"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="m2.5 8.5 4 4 7-9" />
  </svg>
);
```

## Usage guidelines

- **One turn's container** — `Message.Root` reports the role and position as data attributes and renders no layout of its own.
- **Segmented text** — `Message.Text` reconstructs inline chips from the wire format and takes render callbacks for both runs and chips.
- **Everything else is yours** — bubbles, avatars, copy buttons, source pills, attachment previews and markdown rendering are composed by you around these parts.
- **Get started** — see [Quick start](/quick-start) to add the package.

## Anatomy

The package provides three parts. `Message.Root` is the only required one:

```tsx
<Message.Turn>
  <Message.Root role={role} isLast={isLast} isError={isError}>
    <Message.Text>{text}</Message.Text>
  </Message.Root>
</Message.Turn>
```

Everything a finished chat row needs beyond that — the bubble surface, actions,
sources, attachments, markdown — is your own markup, styled off the root's data
attributes:

```tsx
<Message.Root role="assistant" isLast className="group flex flex-col gap-2">
  <Markdown>{content}</Markdown>

  <div className="flex gap-1 opacity-0 group-hover:opacity-100">
    <button type="button" onClick={() => copy(content)}>Copy</button>
    <button type="button" onClick={regenerate}>Regenerate</button>
  </div>
</Message.Root>
```

## Performance

Message is compositional — you pass its parts as children — which means the
package cannot memoize rows for you: a parent re-render re-creates the children
elements, so a `memo` inside `Message` would compare fresh trees and never
bail. The memo boundary has to be **your row component**, the one that receives
the message object and derives everything inside:

```tsx
const ChatMessageItem = memo(({ message, isLast, isStreaming }: ChatMessageItemProps) => {
  const { parts } = message;
  // segmentation, part mapping, actions — all derived in here
  return <Message.Root role={message.role} isLast={isLast}>{/* … */}</Message.Root>;
});

{messages.map((message) => (
  <ChatMessageItem
    key={message.id}
    message={message}
    isLast={message.id === lastMessageId}
    isStreaming={message.id === lastMessageId && isStreaming}
  />
))}
```

Three rules keep the memo effective while a reply streams:

- **Pass the original message object.** Finished messages keep reference
  identity across stream chunks; spreading (`{ parts, ...message }`) mints a
  fresh object every render and silently defeats the memo.
- **Make flags per-message.** `isStreaming` should mean *this message is
  streaming* — passing the chat-wide status re-renders every row on each
  status transition.
- **Take callbacks from stable context inside the row**, not as inline props
  from the map.

Done right, a stream chunk re-renders exactly one row. See
[Composer performance](/primitives/composer#performance) for the full render model.

## Accessibility

- **Role** drives `data-role`, and error/last state drive `data-error`/`data-last`
  on the root, so styling and assistive context stay in sync.
- **Speaker identity is yours to announce.** `role` is an opaque string the
  package only surfaces as `data-role` — alignment and colour are invisible to
  assistive tech. Give each message a visually-hidden `{role} said` prefix, or
  an `aria-label` on the root, so a transcript read top-to-bottom attributes
  its turns.
- **Error, stopped and loading markers** are your markup: give an inline failure
  marker `role="alert"` so it announces immediately, and quieter states
  ("stopped", "generating…") `role="status"`.
- **Actions** you add should be real buttons with accessible names.

## API reference

All three parts accept `className`, `style`, and `render`
(see [Styling](/handbook/styling)).

### Message.Root

The container. Renders `data-message`.

export const rootProps = [
  { name: "role", type: "string", default: "(required)", description: "Opaque role string surfaced as data-role; you own the set (commonly system / user / assistant)." },
  { name: "isLast", type: "boolean", default: "false", description: "Marks the last message (data-last) — a streaming/animation hook." },
  { name: "isError", type: "boolean", default: "false", description: "Marks the message as failed (data-error)." },
];

<PropsTable rows={rootProps} />

export const rootAttrs = [
  { attribute: "data-message", description: "The message root." },
  { attribute: "data-role", values: "string", description: "The message role you passed (commonly system / user / assistant)." },
  { attribute: "data-error", description: "Present when isError is true." },
  { attribute: "data-last", description: "Present when isLast is true." },
];

<AttributesTable rows={rootAttrs} />

### Message.Turn

Groups consecutive messages from one role into a single visual turn. Renders
`data-message-turn` and takes no props of its own beyond the shared ones.

### Message.Text

Plain text with inline chips reconstructed from the wire format. Renders
`data-message-text`.

export const textProps = [
  { name: "children", type: "string", default: "(required)", description: "The message text; chip tokens are parsed out and rendered." },
  { name: "renderText", type: "(text, index) => ReactNode", description: "Custom renderer for plain text runs." },
  { name: "renderChip", type: "(chip, index) => ReactNode", description: "Custom renderer for reconstructed chips." },
];

<PropsTable rows={textProps} />

### Selection

Text selection scoped to a message is exposed as functions rather than a part,
so the toolbar (or whatever you build on it) stays yours.

export const hooks = [
  { name: "useMessageSelection", type: "(scope: HTMLElement | null) => MessageSelection | null", description: "Subscribe to the text selection scoped to a message element; settles on mouseup/keyup." },
  { name: "useMessageSelectionScope", type: "() => { anchorRef, contentElement }", description: "Resolve the owning message's content element from an anchor rendered inside it." },
  { name: "readMessageSelection", type: "(scope: HTMLElement) => MessageSelection | null", description: "Read the current selection once, without subscribing." },
];

<PropsTable rows={hooks} />

### Types

`MessageSelection`, `MessageState`, `MessageChipSegment`, `MessageRootProps`,
`MessageTurnProps`, and `MessageTextProps` are exported from
`@intentface/chat/message`.
