---
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 { IconCheck, IconCopy } from "@tabler/icons-react";
import { 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 ? <IconCheck className="size-3.5" /> : <IconCopy className="size-4" />}
    </button>
  );
};
```

## 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.
- **Memoise your row, not ours** — see [Why the memo boundary is yours](#why-the-memo-boundary-is-yours). Getting this wrong re-renders every message on every stream chunk.
- **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>
```

## Examples

### Reconstructing chips from the text

The wire format is a markdown-shaped link, `[Label](chip:prefix:value)`, with
everything the chip needs inside the token. A stored message therefore rebuilds
its own chips from its text alone, with no sidecar metadata to keep in sync.
`renderChip` decides what one looks like at render time.

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

import { Chip } from "@intentface/chat/chip";
import { Message, type MessageChipSegment } from "@intentface/chat/message";
import { IconFile, IconFileText, IconTool } from "@tabler/icons-react";
import type { ComponentProps } from "react";

/*
 * A message whose text carries inline chip references, and the renderers that
 * turn them back into chips.
 *
 * The wire format is a markdown-shaped link: `[Label](chip:prefix:value)`, with
 * everything the chip needs riding along inside the token. That is the point —
 * a stored message reconstructs its own chips from its text alone, with no
 * sidecar array of metadata to keep in sync or migrate.
 *
 * `Message.Text` parses the tokens and calls `renderChip` for each one. What a
 * chip looks like, and whether a prefix earns a different variant, is decided
 * here at render time rather than baked into the stored text.
 */
const TEXT =
  "I compared [pricing.tsx](chip:file:src/app/pricing.tsx) against " +
  "[the Q3 brief](chip:doc:q3-brief) and pulled figures from " +
  "[web-search](chip:tool:web-search). The deprecated rate in " +
  "[legacy.ts](chip:file:src/legacy.ts) is the only mismatch.";

export const Chips = () => (
  <div className="w-full max-w-xl">
    {/* biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role */}
    <Message.Root role="assistant">
      <Message.Text
        renderChip={renderChip}
        className="text-[#1a1a1a] text-sm leading-8 dark:text-[#fcfcfc]"
      >
        {TEXT}
      </Message.Text>
    </Message.Root>
  </div>
);

/** The prefix decides the variant and the icon; neither is stored in the text. */
const renderChip = (chip: MessageChipSegment, index: number) => (
  <Chip.Root
    key={`${chip.prefix}-${chip.value}-${index}`}
    variant={chip.prefix === "tool" ? "accent" : "primary"}
    className={chipClass}
  >
    <Chip.Icon className="flex items-center">
      {chip.prefix === "file" ? (
        <IconFile className="size-4" />
      ) : chip.prefix === "doc" ? (
        <IconFileText className="size-4" />
      ) : (
        <IconTool className="size-4" />
      )}
    </Chip.Icon>
    <Chip.Label>{chip.label}</Chip.Label>
  </Chip.Root>
);

const chipClass =
  "mx-0.5 inline-flex items-center gap-1 rounded-md border border-[#f0f0f0] bg-[#f4f4f4] px-1.5 py-0.5 align-baseline font-medium text-xs data-[variant=accent]:border-blue-200 data-[variant=accent]:bg-blue-50 data-[variant=accent]:text-blue-700 dark:border-[#2d2d2d] dark:bg-[#232323] dark:data-[variant=accent]:border-blue-900 dark:data-[variant=accent]:bg-blue-950 dark:data-[variant=accent]:text-blue-300";

const _icon = (props: ComponentProps<"svg">) => ({
  viewBox: "0 0 16 16",
  fill: "none",
  stroke: "currentColor",
  strokeWidth: 1.3,
  className: "size-3",
  "aria-hidden": true,
  ...props,
});
```

### Acting on a text selection

Selection is a hook rather than a part, so the toolbar it drives stays yours.
`useMessageSelection` takes the element to scope to and reports the settled
selection inside it. Scoping is the point: a drag across two messages, or
anywhere else on the page, reports nothing.

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

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

/*
 * Selection is exposed as a hook rather than a part, so the toolbar it drives
 * stays entirely yours — this one is a small bar, but a popover anchored to the
 * range would read the same value.
 *
 * `useMessageSelection` takes the element to scope to and returns the settled
 * selection inside it, or null. Scoping is the whole point: dragging across two
 * messages, or selecting in the page around them, reports nothing here.
 */
export const Selection = () => {
  const [scope, setScope] = useState<HTMLElement | null>(null);
  const selection = useMessageSelection(scope);
  const [quoted, setQuoted] = useState<string | null>(null);

  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      {/* biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role */}
      <Message.Root
        role="assistant"
        ref={setScope}
        className="rounded-xl border border-[#f0f0f0] bg-white p-4 dark:border-[#262626] dark:bg-[#181818]"
      >
        <Message.Text className="text-[#1a1a1a] text-sm leading-[1.7] dark:text-[#fcfcfc]">
          useMemo caches a computed value and useCallback caches a function reference. Reach for
          either only when something downstream is memoised, because the comparison itself is not
          free. Select any of this sentence.
        </Message.Text>
      </Message.Root>

      {/* Rendered outside the message, and still scoped to it. */}
      <div className="flex min-h-8 items-center justify-center gap-2">
        {selection ? (
          <>
            <span className="max-w-64 truncate text-[#949494] text-xs dark:text-[#6f6f6f]">
              “{selection.text}”
            </span>
            <button
              type="button"
              onClick={() => setQuoted(selection.text)}
              className="h-8 shrink-0 cursor-pointer rounded-full border border-[#e4e4e4] bg-white px-4 font-medium text-[#1a1a1a] text-sm transition-colors hover:bg-[#f4f4f4] dark:border-[#2d2d2d] dark:bg-[#181818] dark:text-[#fcfcfc] dark:hover:bg-[#232323]"
            >
              Quote
            </button>
          </>
        ) : (
          <span className="text-[#949494] text-xs dark:text-[#6f6f6f]">
            Nothing selected in this message.
          </span>
        )}
      </div>

      {quoted && (
        <blockquote className="border-[#e4e4e4] border-l-2 pl-3 text-[#686868] text-sm italic dark:border-[#2d2d2d] dark:text-[#9b9b9b]">
          {quoted}
        </blockquote>
      )}
    </div>
  );
};
```

## Why the memo boundary is yours

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.

## API reference

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

### Message.Root

One turn's container. Reports the role and position as data attributes and
renders no layout of its own, so the bubble, avatar and actions around it stay
yours. Renders a `<div>` element.

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 a
`<div>` element, and takes no props of its own beyond the shared ones.

### Message.Text

Message text with inline chips reconstructed from the wire format, so a stored
message rebuilds its own chips with no sidecar metadata. Renders a `<span>`
element.

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`.
