# Message

> Chat message (user/assistant) with avatar, hover actions, ‹ 2/3 › branches, error/streaming states and an autoscrolling MessageList thread.

- **Slug:** `message` · **Category:** conversation · **Status:** beta
- **Import:** `import { Message, MessageAction, MessageActions, MessageAvatar, MessageBranch, MessageCopyButton, MessageFeedback, MessageList } from "@kura-ui/react/message";`
- **Docs:** https://kuro.abstergo.fyi/components/message
- **Registry item:** https://kuro.abstergo.fyi/r/message.json
- **Tags:** chat, message, conversation, thread, bubble, autoscroll, feedback, actions

## Install

```bash
# shadcn registry (copies source into your project)
npx shadcn@latest add https://kuro.abstergo.fyi/r/message.json
# Kura CLI (same, plus dependency resolution and --json output)
npx @kura-ui/cli add message
# npm package
npm install @kura-ui/react
```

## When to use

- Render the turns of a chat with an LLM: user bubbles on the right, assistant answers as open text on the left.
- Add per-message actions: copy, regenerate, edit, thumbs up/down and navigation between regenerated versions.
- Show a failed generation with an inline error and a Retry button.
- Wrap a whole conversation in MessageList for stick-to-bottom autoscroll with a “Jump to latest” pill.

## When not to use

- Only the streaming answer text, without chrome — use streaming-text.
- A model's hidden chain of thought — put a reasoning block inside the assistant Message.
- A tool invocation or an agent plan — use tool-call or agent-timeline inside the assistant Message.
- The input box at the bottom of a chat — use composer.

## Props

| Prop | Type | Default | Required | Description |
| --- | --- | --- | --- | --- |
| `from` | `"user" \| "assistant"` | — | yes | Who wrote it. user = right-aligned gray bubble with a tucked bottom-right corner; assistant = full-width open text. |
| `name` | `React.ReactNode` | — | no | Display name in the header, e.g. "Kura" or "You". |
| `avatar` | `React.ReactNode` | — | no | Avatar element, usually `<MessageAvatar name src? fallback? />`. |
| `timestamp` | `Date \| number \| string` | — | no | Sent time. A Date/epoch renders as a localized time (“10:42 AM”) in a <time> element with the full date as title; a string renders as-is. |
| `status` | `"idle" \| "streaming" \| "error"` | `"idle"` | no | streaming: sets aria-busy and keeps the action row invisible + inert (still laid out — no shift when it appears). error: dims the content and shows `error` with an optional Retry. |
| `error` | `React.ReactNode` | `"Something went wrong."` | no | Error text for status="error". |
| `onRetry` | `() => void` | — | no | Adds a Retry button to the error row. |
| `actions` | `React.ReactNode` | — | no | Action bar under the content, usually `<MessageActions>` with MessageCopyButton, MessageAction, MessageFeedback, MessageBranch. |

Native attributes of the root element, `className` and `ref` are passed through.

## Anatomy

- **MessageList** (`data-slot="message-list"`) — Thread container (`role="log"` viewport). Sticks to the bottom while content grows, releases when the reader scrolls up, shows a “Jump to latest” pill. `data-at-bottom`.
  - `stickToBottom`: `boolean` = `true` — Follow new content while the reader is at the bottom.
  - `threshold`: `number` = `64` — Px from the bottom that still counts as at-bottom.
  - `jumpLabel`: `string` = `"Jump to latest"` — Label of the jump pill.
  - `contentClassName`: `string` — Classes for the inner column (default `flex flex-col gap-5 px-4 py-6`).
- **Message** (`data-slot="message"`) — Root. `data-from`, `data-status`, `aria-busy` while streaming.
- **Header** (`data-slot="message-header"`) — Name and time.
- **Body** (`data-slot="message-body"`) — Content slot: a flex column with gap-3, with markdown-friendly styles for p, ul/ol, a, strong, inline code, blockquote and h1–h3. Drop in rendered markdown, StreamingText or CodeBlocks.
- **Error** (`data-slot="message-error"`) — role="alert" row for status="error".
- **MessageAvatar** (`data-slot="message-avatar"`) — 28px round avatar: image → custom fallback → initials.
  - `name`: `string` — Alt text and initials source.
  - `src`: `string` — Image URL; broken images fall back.
  - `fallback`: `React.ReactNode` — Custom fallback such as a logo or lucide icon.
- **MessageActions** (`data-slot="message-actions"`) — role="group" action bar; fades in on hover/focus-within, always visible on coarse pointers.
  - `visibility`: `"hover" | "always"` = `"hover"` — Reveal on hover/focus, or always show.
- **MessageAction** (`data-slot="message-action"`) — Small labelled IconButton (tooltip included) for regenerate, edit, share…
- **MessageCopyButton** — Copy action; the icon cross-fades to a check for 1.6s and a polite status announces it.
  - `text`: `string` — Text to copy (usually the raw markdown).
  - `onCopied`: `() => void` — Called after a successful copy.
- **MessageFeedback** (`data-slot="message-feedback"`) — Thumbs up/down toggles (aria-pressed); pressing the active one clears it. `data-value`.
  - `value`: `"up" | "down" | null` — Controlled rating.
  - `defaultValue`: `"up" | "down" | null` = `null` — Initial rating when uncontrolled.
  - `onValueChange`: `(value: "up" | "down" | null) => void` — Called with the new rating.
- **MessageBranch** (`data-slot="message-branch"`) — “‹ 2/3 ›” version switcher for regenerations/edits; renders nothing when total ≤ 1.
  - `current`: `number` — 1-based visible version.
  - `total`: `number` — Number of versions.
  - `onCurrentChange`: `(current: number) => void` — Called with the new 1-based index.
- **Jump** (`data-slot="message-list-jump"`) — “Jump to latest” pill; `data-state="visible" | "hidden"` (inert when hidden).

## Accessibility

- Role: `log`
- MessageList's viewport is `role="log"` (implicitly polite) labelled “Conversation”, and focusable so the thread scrolls with the keyboard. A streaming Message sets `aria-busy`, so assistive tech announces the finished answer rather than each token.
- Every action is a labelled icon button with a tooltip; feedback toggles use aria-pressed; the branch switcher announces “Version 2 of 3”.
- Actions are revealed on hover AND keyboard focus (focus-within), and are always visible on touch devices.
- The error row is `role="alert"`.
- The hidden jump pill is inert and aria-hidden, so it never traps focus.
- Reduced motion: Jump-to-latest scrolls instantly and its pill only fades (no slide/scale); the copy check and thumbs swap without scale/blur.

| Keys | Action |
| --- | --- |
| Tab | Moves into a message's actions (revealing the bar) and through them |
| ↑ / ↓ / Page Up / Page Down | Scroll the focused thread |
| Enter / Space | Activate an action; toggle feedback |

## Notes for agents

- Give MessageList a height (h-[480px], flex-1 inside a flex column, …) — it scrolls internally.
- Use `from`, not `role`, for the author (role is the ARIA attribute).
- Set status="streaming" on the assistant Message while StreamingText is running, then "idle" in its onComplete; pass `actions` from the start so the row is reserved.
- Message renders children inside a flex column with gap-3 — pass block elements (p, CodeBlock, lists) directly; do not add margins.
- For markdown, render it to elements (react-markdown etc.) inside Message; inline code, links, lists and blockquotes are styled by the body.
- Keep MessageBranch state (current version) in your store alongside the alternative texts.

## Examples

### Two turns (minimal)

A user bubble and an assistant answer.

```tsx
import { Message } from "@kura-ui/react/message";

export function MessageMinimal() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <Message from="user">How do I make a like button feel instant?</Message>
      <Message from="assistant">
        Render the new count optimistically and reconcile when the request finishes.
      </Message>
    </div>
  );
}
```

### Avatars, actions and versions (full)

Name, time, avatar, copy/regenerate/edit, controlled thumbs, ‹ 2/3 › branches. Regenerate simulates an error with Retry.

```tsx
"use client";

import { Pencil, RefreshCw } from "lucide-react";
import * as React from "react";
import {
  Message,
  MessageAction,
  MessageActions,
  MessageAvatar,
  MessageBranch,
  MessageCopyButton,
  MessageFeedback,
} from "@kura-ui/react/message";

const VERSIONS = [
  "Use `useOptimistic` for the like button: render the optimistic count immediately, then reconcile when the server action resolves.",
  "Wrap the mutation in a transition and render from `useOptimistic`. If the action throws, React drops the optimistic value and the real count comes back on its own.",
  "Keep it simple: update local state on click, call the server action, and roll back in a `catch`. `useOptimistic` saves you that rollback.",
];

export function MessageFull() {
  const [version, setVersion] = React.useState(2);
  const [rating, setRating] = React.useState<"up" | "down" | null>("up");
  const [failed, setFailed] = React.useState(false);
  const text = VERSIONS[version - 1]!;
  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <Message
        from="user"
        name="You"
        timestamp="10:41"
        avatar={<MessageAvatar name="Ada Lovelace" />}
        actions={
          <MessageActions>
            <MessageAction label="Edit" icon={<Pencil />} />
            <MessageCopyButton text="Why does my like button lag?" />
          </MessageActions>
        }
      >
        Why does my like button lag?
      </Message>
      <Message
        from="assistant"
        name="Kura"
        timestamp="10:42"
        avatar={<MessageAvatar name="Kura" />}
        status={failed ? "error" : "idle"}
        error="The model stopped responding. Partial answer shown."
        onRetry={() => setFailed(false)}
        actions={
          <MessageActions visibility="always">
            <MessageCopyButton text={text} />
            <MessageAction
              label="Regenerate"
              icon={<RefreshCw />}
              onClick={() => setFailed(true)}
            />
            <MessageFeedback value={rating} onValueChange={setRating} />
            <MessageBranch current={version} total={VERSIONS.length} onCurrentChange={setVersion} />
          </MessageActions>
        }
      >
        <p key={version} className="animate-kura-fade-in">
          {text.split("`").map((part, i) => (i % 2 ? <code key={i}>{part}</code> : part))}
        </p>
      </Message>
    </div>
  );
}
```

### Streaming and error (state)

A streaming answer with reserved actions, and a failed one.

```tsx
"use client";

import * as React from "react";
import { StreamingText } from "@kura-ui/react/streaming-text";
import { Message, MessageActions, MessageAvatar, MessageCopyButton } from "@kura-ui/react/message";

async function* tokens(text: string): AsyncGenerator<string> {
  const words = text.split(/(?<=\s)/);
  for (let i = 0; i < words.length; ) {
    const n = 1 + Math.floor(Math.random() * 3);
    yield words.slice(i, i + n).join("");
    i += n;
    await new Promise((r) => setTimeout(r, 30 + Math.random() * 110));
  }
}

export function MessageStates() {
  const [stream] = React.useState(() =>
    tokens(
      "Reading the trace now. The slow part is the waterfall: the like request waits on the session refresh, so the button cannot update until both finish.",
    ),
  );
  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <Message
        from="assistant"
        name="Kura"
        avatar={<MessageAvatar name="Kura" />}
        status="streaming"
        actions={
          <MessageActions>
            <MessageCopyButton text="" />
          </MessageActions>
        }
      >
        <StreamingText stream={stream} />
      </Message>
      <Message
        from="assistant"
        name="Kura"
        avatar={<MessageAvatar name="Kura" />}
        status="error"
        error="Rate limited — try again in a few seconds."
        onRetry={() => {}}
      >
        I started profiling the
      </Message>
    </div>
  );
}
```

### Autoscrolling thread (composition)

MessageList with StreamingText, CodeBlock and a shimmering “Thinking…” turn. Scroll up mid-stream to see “Jump to latest”.

```tsx
"use client";

import { ArrowUp, RefreshCw } from "lucide-react";
import * as React from "react";
import { Button } from "@kura-ui/react/button";
import { CodeBlock } from "@kura-ui/react/code-block";
import { ShimmerText } from "@kura-ui/react/shimmer-text";
import { StreamingText } from "@kura-ui/react/streaming-text";
import { Message, MessageAction, MessageActions, MessageAvatar, MessageCopyButton, MessageFeedback } from "@kura-ui/react/message";
import { MessageList } from "@kura-ui/react/message";

async function* tokens(text: string): AsyncGenerator<string> {
  const words = text.split(/(?<=\s)/);
  for (let i = 0; i < words.length; ) {
    const n = 1 + Math.floor(Math.random() * 3);
    yield words.slice(i, i + n).join("");
    i += n;
    await new Promise((r) => setTimeout(r, 30 + Math.random() * 110));
  }
}

const LONG_ANSWER =
  "Three things will make the thread feel calm.\n\nFirst, stick to the bottom only while the reader is there. The moment they scroll up, stop following — nothing is worse than being yanked back mid-sentence.\n\nSecond, reserve space for things that appear later, like the action bar, so the end of a stream never shifts the layout.\n\nThird, announce the finished message, not every token: the log is `aria-busy` while the answer streams.";

export function MessageThread() {
  const [turns, setTurns] = React.useState<Turn[]>([
    { id: 1, from: "user", text: "Can you review the chat layout?" },
    {
      id: 2,
      from: "assistant",
      text: "Sure. The structure is solid; two details stand out. The composer jumps when the keyboard opens on iOS, and new messages push the reader down even when they are reading older ones.",
    },
    { id: 3, from: "user", text: "Show me the fix for the composer." },
    { id: 4, from: "assistant", text: "Pin it with dynamic viewport units:" },
  ]);
  const [streaming, setStreaming] = React.useState<number | null>(null);
  const [waiting, setWaiting] = React.useState(false);

  const ask = () => {
    const id = Date.now();
    setTurns((t) => [...t, { id, from: "user", text: "How should the thread scroll?" }]);
    setWaiting(true);
    setTimeout(() => {
      setWaiting(false);
      setStreaming(id + 1);
      setTurns((t) => [
        ...t,
        { id: id + 1, from: "assistant", text: "", stream: tokens(LONG_ANSWER) },
      ]);
    }, 900);
  };

  return (
    <div className="flex h-[440px] w-full max-w-xl flex-col overflow-hidden rounded-kura-xl bg-kura-surface shadow-kura-sm">
      <MessageList className="flex-1" contentClassName="px-5">
        {turns.map((turn) => (
          <Message
            key={turn.id}
            from={turn.from}
            status={streaming === turn.id ? "streaming" : "idle"}
            avatar={turn.from === "assistant" ? <MessageAvatar name="Kura" /> : undefined}
            actions={
              turn.from === "assistant" ? (
                <MessageActions>
                  <MessageCopyButton text={turn.text} />
                  <MessageAction label="Regenerate" icon={<RefreshCw />} />
                  <MessageFeedback />
                </MessageActions>
              ) : undefined
            }
          >
            {turn.stream ? (
              <StreamingText stream={turn.stream} onComplete={() => setStreaming(null)} />
            ) : (
              turn.text
            )}
            {turn.id === 4 && (
              <CodeBlock
                language="css"
                filename="composer.css"
                code={".composer {\n  position: sticky;\n  bottom: 0;\n  max-height: 40dvh;\n}"}
              />
            )}
          </Message>
        ))}
        {waiting && (
          <Message from="assistant" avatar={<MessageAvatar name="Kura" />} status="streaming">
            <ShimmerText>Thinking…</ShimmerText>
          </Message>
        )}
      </MessageList>
      <div className="flex items-center justify-between gap-3 px-4 py-3 shadow-[inset_0_1px_0_var(--kura-border-subtle)]">
        <span className="text-kura-fg-subtle text-xs">Scroll up while it streams.</span>
        <Button size="sm" onClick={ask} disabled={waiting || streaming !== null}>
          Ask a follow-up <ArrowUp />
        </Button>
      </div>
    </div>
  );
}
```

## Dependencies

- npm: `lucide-react`
- Kura registry items: `kura-theme`, `button`, `icon-button`, `use-controllable-state`, `use-reduced-motion`, `utils`
- Related: `streaming-text`, `code-block`, `reasoning`, `tool-call`, `citation`, `composer`
