Message

A role-aware container for one turn, with chip-segmented text and selection hooks.

How do I center a div?
Use flexbox on the parent: display: flex, then justify-content: center and align-items: center.
"use client";

import { Message } from "@intentface/chat/message";
import { Check, Copy } from "@keyline-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-5">
    {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.5 data-[role=user]:items-end"
      >
        {/* data-role sits on Root, so the bubble reads it through the group. */}
        <Message.Text className="text-sm text-zinc-700 leading-6 group-data-[role=user]:max-w-[80%] group-data-[role=user]:rounded-[20px] group-data-[role=user]:bg-white group-data-[role=user]:px-3.5 group-data-[role=user]:py-1.5 group-data-[role=user]:text-zinc-900 group-data-[role=user]:leading-6 group-data-[role=user]:shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_rgb(0_0_0/0.04)] dark:text-zinc-300 dark:group-data-[role=user]:bg-zinc-800 dark:group-data-[role=user]:text-zinc-100 dark:group-data-[role=user]:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16)]">
          {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="-ml-1.5 flex size-7 cursor-pointer items-center justify-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-500 dark:hover:bg-white/8 dark:hover:text-zinc-100"
    >
      {copied ? <Check className="size-[15px]" /> : <Copy className="size-[15px]" />}
    </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. Getting this wrong re-renders every message on every stream chunk.
  • Get started — see 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.

I compared pricing.tsx against the Q3 brief and pulled figures from web-search. The deprecated rate in legacy.ts is the only mismatch.
"use client";

import { Chip } from "@intentface/chat/chip";
import { Message, type MessageChipSegment } from "@intentface/chat/message";
import { File, FileText, Wrench } from "@keyline-icons/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-sm text-zinc-700 leading-8 dark:text-zinc-300"
      >
        {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="mx-0.5 inline-flex h-6 items-center gap-1 rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-2 align-middle font-medium text-xs text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] data-[variant=accent]:bg-[#0169cc]/10 data-[variant=accent]:bg-none data-[variant=accent]:text-[#0169cc] data-[variant=accent]:shadow-none dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:data-[variant=accent]:bg-[#4c9bea]/15 dark:data-[variant=accent]:text-[#4c9bea] dark:data-[variant=accent]:shadow-none"
  >
    <Chip.Icon className="flex items-center">
      {chip.prefix === "file" ? (
        <File className="size-3.5" />
      ) : chip.prefix === "doc" ? (
        <FileText className="size-3.5" />
      ) : (
        <Wrench className="size-3.5" />
      )}
    </Chip.Icon>
    <Chip.Label>{chip.label}</Chip.Label>
  </Chip.Root>
);

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.

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.
Nothing selected in this message.
"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 bg-white px-4 py-3.5 shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]"
      >
        <Message.Text className="text-sm text-zinc-700 leading-6 dark:text-zinc-300">
          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-xs text-zinc-400 dark:text-zinc-500">
              “{selection.text}”
            </span>
            <button
              type="button"
              onClick={() => setQuoted(selection.text)}
              className="h-8 shrink-0 cursor-pointer rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-3.5 font-medium text-[13px] text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] hover:from-[#fafafa] hover:to-[#f6f6f6] focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134]"
            >
              Quote
            </button>
          </>
        ) : (
          <span className="text-xs text-zinc-400 dark:text-zinc-500">
            Nothing selected in this message.
          </span>
        )}
      </div>

      {quoted && (
        <blockquote className="border-zinc-950/10 border-l-2 pl-3 text-sm text-zinc-500 italic leading-6 dark:border-white/10 dark:text-zinc-400">
          {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 for the full render model.

API reference

All three parts accept className, style, and render (see 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.

PropTypeDefaultDetails
rolestring(required)
isLastbooleanfalse
isErrorbooleanfalse
AttributeDescription
data-message
The message root.
data-role
The message role you passed (commonly system / user / assistant).string
data-error
Present when isError is true.
data-last
Present when isLast is true.

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.

PropTypeDefaultDetails
childrenstring(required)
renderText(text, index) => ReactNode—
renderChip(chip, index) => ReactNode—

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.

PropTypeDefaultDetails
useMessageSelection(scope: HTMLElement | null) => MessageSelection | null—
useMessageSelectionScope() => { anchorRef, contentElement }—
readMessageSelection(scope: HTMLElement) => MessageSelection | null—

Types

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