Message

One turn of a conversation with an assistant: a person's bubble, or an answer set as prose with actions under it.

'use client';

import { RotateCcwIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';
import { useState } from 'react';
import { Message } from '@/components/panelui/message';

const ANSWER =
  'Scale everything by 1.5, then round to what a kitchen measures. 300 g flour, 3 eggs, 375 ml milk and a pinch more salt. Bake in two tins rather than one deep one, at the same 180 °C for 25 minutes.';


export default function MessageConversation() {
  const [rating, setRating] = useState<'up' | 'down' | null>(null);

  return (
    <div className="flex w-full max-w-xl flex-col gap-8">
      <Message from="user">
        <Message.Content>
          <Message.Bubble>
            <Message.BubbleContent>This sponge serves four. How do I make it for six?</Message.BubbleContent>
          </Message.Bubble>
        </Message.Content>
      </Message>

      <Message from="assistant">
        <Message.Content>
          <Message.Bubble>
            <Message.BubbleContent>
              <p>
                Multiply by <strong>1.5</strong> and round to what you can weigh:
              </p>
              <ul>
                <li>300 g self-raising flour</li>
                <li>3 eggs, where the original had two</li>
                <li>375 ml milk</li>
              </ul>
              <p>Bake in two tins rather than one deeper one, at the same 180 °C for about 25 minutes.</p>
            </Message.BubbleContent>
          </Message.Bubble>
          <Message.Actions>
            <Message.Action label="Copy" copy={ANSWER} />
            <Message.Action label="Good answer" pressed={rating === 'up'} onClick={() => setRating(rating === 'up' ? null : 'up')}>
              <ThumbsUpIcon />
            </Message.Action>
            <Message.Action label="Bad answer" pressed={rating === 'down'} onClick={() => setRating(rating === 'down' ? null : 'down')}>
              <ThumbsDownIcon />
            </Message.Action>
            <Message.Action label="Try again">
              <RotateCcwIcon />
            </Message.Action>
          </Message.Actions>
        </Message.Content>
      </Message>

      <Message from="user">
        <Message.Content>
          <Message.Bubble>
            <Message.BubbleContent>And without eggs?</Message.BubbleContent>
          </Message.Bubble>
        </Message.Content>
      </Message>

      <Message from="assistant" streaming>
        <Message.Content>
          <Message.Bubble>
            <Message.BubbleContent>
              <p>Use 3 tablespoons of aquafaba for each egg, whisked until it holds soft peaks. Fold it in last, and</p>
            </Message.BubbleContent>
          </Message.Bubble>
        </Message.Content>
      </Message>
    </div>
  );
}

Use it for the turns of a conversation with an assistant. from="user" is what the person wrote, in a bubble on the trailing side. from="assistant" is the answer, set as prose across the full width with no bubble, because an answer is long and is the thing being read.

Message.BubbleContent sets paragraphs, lists, inline code, code blocks and headings, so Markdown rendered to HTML can go straight in.

It lays turns out and does not scroll them. Put the turns in a scrolling column of your own, and the composer under it; Prompt is one.

Installation

npx shadcn@latest add https://panelui.dev/r/web/message.json

The source is copied into components/panelui/, where you can edit it.

npm install panelui-web

Import from panelui-web/message instead of @/components/panelui/message. Tailwind needs one line to read the package's classes, which is on the installation page.

Install the packages it uses:

npm install @base-ui/react tailwind-variants

Copy the source into components/panelui/message.tsx:

Usage

import { Message } from "@/components/panelui/message"
<Message from="user">
  <Message.Content>
    <Message.Bubble>
      <Message.BubbleContent>How do I make it for six?</Message.BubbleContent>
    </Message.Bubble>
  </Message.Content>
</Message>

<Message from="assistant">
  <Message.Content>
    <Message.Bubble>
      <Message.BubbleContent>Multiply by 1.5…</Message.BubbleContent>
    </Message.Bubble>
    <Message.Actions>
      <Message.Action label="Copy" copy={answer} />
    </Message.Actions>
  </Message.Content>
</Message>

Composition

<Message.Group>
  <Message>
    <Message.Avatar />
    <Message.Content>
      <Message.Header />
      <Message.Attachments>
        <Message.Attachment />
      </Message.Attachments>
      <Message.Status />
      <Message.Bubble>
        <Message.BubbleContent />
      </Message.Bubble>
      <Message.Footer />
      <Message.Actions>
        <Message.Branch />
        <Message.Action />
      </Message.Actions>
    </Message.Content>
  </Message>
</Message.Group>
  • Message.Group — Turns in a row from the same side, set closer together. Its from applies to every Message in it.
  • Message.Avatar — Optional: who the turn is from, beside its first line. An assistant's answer reads best without one, as plain text; use it where several people share a thread. Put an Avatar in it.
  • Message.Content — The column that holds the rest. On a person's turn it is at most 80% of the width.
  • Message.Header — A line above the turn: a name in <strong>, a time.
  • Message.Attachments — The files sent with a turn, above its bubble.
  • Message.Attachment — One file. With src it is a picture; without, a card with an icon, the name and a meta line.
  • Message.Status — What the assistant is doing before its first word, as a line that shimmers. It is a role="status", so it is read out as it changes. Swap it for the bubble when the text starts.
  • Message.Bubble — The surface. A person's turn is drawn in a muted bubble; an assistant's draws nothing.
  • Message.BubbleContent — The text, set as prose. With streaming on the Message, a caret follows the last word.
  • Message.Footer — A line under the turn: seen, edited, how long it took.
  • Message.Actions — The row of actions under an answer. reveal="hover" hides it until the turn is hovered or focused, for answers before the latest; on a touch screen it always shows.
  • Message.Action — An icon button with a tooltip. label is required, because the button has no text. copy puts its text on the clipboard and shows a check for a moment. pressed makes it a toggle, such as a thumbs-up.
  • Message.Branch — "‹ 2 / 3 ›", for stepping between versions of an answer that was retried or edited. count, index from 0, and onIndexChange.

Examples

Actions and versions

Copy, rate, retry and share, with Message.Branch to step between versions. Retry here adds a version and shows it. The ratings are pressed toggles.

'use client';

import { RotateCcwIcon, Share2Icon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';
import { useState } from 'react';
import { Message } from '@/components/panelui/message';

/** Three tries at the same answer. Retrying adds a fourth, and the switcher steps between them. */
const VERSIONS = [
  'A cron line runs on fixed times. `0 9 * * 1-5` is nine in the morning on weekdays.',
  'Read it left to right: minute, hour, day of the month, month, day of the week. `0 9 * * 1-5` means minute 0, hour 9, Monday to Friday.',
  'It fires at 09:00 every weekday. The fields are minute, hour, day, month and weekday, so `1-5` limits it to Monday to Friday.',
  'At 09:00 on Monday to Friday. Change the last field to `*` for every day of the week.',
];

export default function MessageActions() {
  const [count, setCount] = useState(3);
  const [index, setIndex] = useState(2);
  const [rating, setRating] = useState<'up' | 'down' | null>(null);

  return (
    <Message from="assistant" className="w-full max-w-xl">
      <Message.Content>
        <Message.Bubble>
          {/* Keyed by the version, so the new text replaces the old one. */}
          <Message.BubbleContent key={index}>
            <p>
              {VERSIONS[index]!.split('`').map((part, at) => (at % 2 ? <code key={at}>{part}</code> : part))}
            </p>
          </Message.BubbleContent>
        </Message.Bubble>
        <Message.Actions>
          <Message.Branch count={count} index={index} onIndexChange={setIndex} />
          <Message.Action label="Copy" copy={() => VERSIONS[index]!} />
          <Message.Action label="Good answer" pressed={rating === 'up'} onClick={() => setRating(rating === 'up' ? null : 'up')}>
            <ThumbsUpIcon />
          </Message.Action>
          <Message.Action label="Bad answer" pressed={rating === 'down'} onClick={() => setRating(rating === 'down' ? null : 'down')}>
            <ThumbsDownIcon />
          </Message.Action>
          <Message.Action
            label="Try again"
            disabled={count === VERSIONS.length}
            onClick={() => {
              setCount(count + 1);
              setIndex(count);
              setRating(null);
            }}
          >
            <RotateCcwIcon />
          </Message.Action>
          <Message.Action label="Share">
            <Share2Icon />
          </Message.Action>
        </Message.Actions>
      </Message.Content>
    </Message>
  );
}

While it is being written

Message.Status holds the place while the assistant works, then the text arrives with streaming set, which draws a caret after the last word and marks the turn busy. The actions appear when it is done.

'use client';

import { RotateCcwIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/panelui/button';
import { Message } from '@/components/panelui/message';

const REPLY =
  'The tide is lowest at 14:12 today, at 0.6 metres. The causeway is usually walkable for about two hours either side, so leave the island by 15:40 at the latest.';

type Phase = 'thinking' | 'writing' | 'done';

export default function MessageStreaming() {
  const [phase, setPhase] = useState<Phase>('thinking');
  const [words, setWords] = useState(0);
  const [run, setRun] = useState(0);
  const timer = useRef<number | undefined>(undefined);

  // Stands in for a model: a moment to look the tides up, then a word at a time.
  useEffect(() => {
    const all = REPLY.split(' ').length;
    setPhase('thinking');
    setWords(0);
    let count = 0;
    timer.current = window.setTimeout(function next() {
      setPhase('writing');
      count += 1 + Math.floor(Math.random() * 2);
      setWords(Math.min(count, all));
      if (count < all) timer.current = window.setTimeout(next, 60 + Math.random() * 90);
      else setPhase('done');
    }, 1600);
    return () => window.clearTimeout(timer.current);
  }, [run]);

  return (
    <div className="flex w-full max-w-xl flex-col gap-6">
      <Message from="user">
        <Message.Content>
          <Message.Bubble>
            <Message.BubbleContent>When can I walk back from the island today?</Message.BubbleContent>
          </Message.Bubble>
        </Message.Content>
      </Message>
      <Message from="assistant" streaming={phase === 'writing'}>
        <Message.Content>
          {phase === 'thinking' ? (
            <Message.Status>Checking today’s tide table</Message.Status>
          ) : (
            <Message.Bubble>
              <Message.BubbleContent>{REPLY.split(' ').slice(0, words).join(' ')}</Message.BubbleContent>
            </Message.Bubble>
          )}
          {phase === 'done' ? (
            <Message.Actions>
              <Message.Action label="Copy" copy={REPLY} />
            </Message.Actions>
          ) : null}
        </Message.Content>
      </Message>
      <Button variant="outline" size="sm" className="self-start" onClick={() => setRun(run + 1)}>
        <RotateCcwIcon /> Replay
      </Button>
    </div>
  );
}

Attachments

Message.Attachments above the bubble: a picture by src, and a file as a card with an icon, its name and a line of detail.

import { FileSpreadsheetIcon } from 'lucide-react';
import { Message } from '@/components/panelui/message';

export default function MessageAttachmentsDemo() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-6">
      <Message from="user">
        <Message.Content>
          <Message.Attachments>
            <Message.Attachment name="Shelf, before" src="https://images.unsplash.com/photo-1555041469-a586c61ea9bc?w=300&q=60" />
            <Message.Attachment name="Stock count.xlsx" meta="Spreadsheet · 48 KB" icon={<FileSpreadsheetIcon />} />
          </Message.Attachments>
          <Message.Bubble>
            <Message.BubbleContent>Which of these are we about to run out of?</Message.BubbleContent>
          </Message.Bubble>
        </Message.Content>
      </Message>
      <Message from="assistant">
        <Message.Content>
          <Message.Bubble>
            <Message.BubbleContent>
              <p>
                Two lines are below a week of stock: <strong>linen cushions</strong> (4 left, 9 sold a week) and the{' '}
                <strong>oak side table</strong> (1 left). Everything on the shelf in the photo has more than a month.
              </p>
            </Message.BubbleContent>
          </Message.Bubble>
        </Message.Content>
      </Message>
    </div>
  );
}

Names, times and groups

In a thread several people share, Message.Header carries the name and Message.Footer a status. Message.Group sets consecutive turns from one side closer together.

import { Message } from '@/components/panelui/message';

export default function MessageTeam() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-6">
      {/* In a shared thread the person's name matters, so the header carries it. */}
      <Message.Group from="user">
        <Message>
          <Message.Content>
            <Message.Header>
              <strong>Noor Haddad</strong> 10:42
            </Message.Header>
            <Message.Bubble>
              <Message.BubbleContent>Summarise the launch thread for Ravi?</Message.BubbleContent>
            </Message.Bubble>
          </Message.Content>
        </Message>
        <Message>
          <Message.Content>
            <Message.Bubble>
              <Message.BubbleContent>Just the decisions, not the discussion.</Message.BubbleContent>
            </Message.Bubble>
            <Message.Footer>Seen by Ravi</Message.Footer>
          </Message.Content>
        </Message>
      </Message.Group>
      <Message from="assistant">
        <Message.Content>
          <Message.Header>
            <strong>Assistant</strong> read 46 messages
          </Message.Header>
          <Message.Bubble>
            <Message.BubbleContent>
              <ul>
                <li>Launch moves to Tuesday 3 November.</li>
                <li>Pricing stays as it is until January.</li>
                <li>Ravi owns the press note.</li>
              </ul>
            </Message.BubbleContent>
          </Message.Bubble>
          <Message.Footer>3 decisions · from 46 messages</Message.Footer>
        </Message.Content>
      </Message>
    </div>
  );
}

With a composer

A conversation that works: write in the Prompt, and a stand-in model answers. Earlier answers keep their actions out of sight until they are hovered.

'use client';

import { useEffect, useRef, useState } from 'react';
import { Message } from '@/components/panelui/message';
import { Prompt, type PromptMessage, type PromptState } from '@/components/panelui/prompt';

interface Turn {
  id: number;
  from: 'user' | 'assistant';
  text: string;
}

const REPLIES = [
  'Good question. Start with the smallest change that tells you something: one page, one week, one number to watch.',
  'I would write it down first. A plan that only lives in your head changes every time you look at it.',
  'Two options: do it now and accept a rough first version, or wait until Friday and do it properly. Both are fine; pick on purpose.',
];

export default function MessageChat() {
  const [turns, setTurns] = useState<Turn[]>([
    { id: 0, from: 'assistant', text: 'Ask me anything. I will answer with something short.' },
  ]);
  const [status, setStatus] = useState<PromptState>('idle');
  const timer = useRef<number | undefined>(undefined);
  const scroller = useRef<HTMLDivElement>(null);
  useEffect(() => () => window.clearTimeout(timer.current), []);
  // Keeps the newest turn in view. The column is scrolled, not the page.
  useEffect(() => {
    const column = scroller.current;
    if (column) column.scrollTop = column.scrollHeight;
  }, [turns, status]);

  // Stands in for a model: a pause, then the reply a few words at a time.
  const send = ({ text }: PromptMessage) => {
    if (!text.trim()) return;
    const reply = REPLIES[turns.length % REPLIES.length]!;
    const id = turns.length + 1;
    setTurns((all) => [...all, { id, from: 'user', text }, { id: id + 1, from: 'assistant', text: '' }]);
    setStatus('submitted');
    const words = reply.split(' ');
    let count = 0;
    timer.current = window.setTimeout(function next() {
      setStatus('streaming');
      count += 2;
      setTurns((all) => all.map((turn) => (turn.id === id + 1 ? { ...turn, text: words.slice(0, count).join(' ') } : turn)));
      if (count < words.length) timer.current = window.setTimeout(next, 90);
      else setStatus('idle');
    }, 900);
  };

  return (
    <div className="flex h-[30rem] w-full max-w-xl flex-col gap-4">
      <div ref={scroller} className="-mx-2 flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-2 pt-2">
        {turns.map((turn, index) => {
          const last = index === turns.length - 1;
          const writing = last && turn.from === 'assistant' && status !== 'idle';
          return (
            <Message key={turn.id} from={turn.from} streaming={writing && status === 'streaming'}>
              <Message.Content>
                {writing && status === 'submitted' ? (
                  <Message.Status>Thinking</Message.Status>
                ) : (
                  <Message.Bubble>
                    <Message.BubbleContent>{turn.text}</Message.BubbleContent>
                  </Message.Bubble>
                )}
                {turn.from === 'assistant' && !writing && index > 0 ? (
                  <Message.Actions reveal={last ? 'always' : 'hover'}>
                    <Message.Action label="Copy" copy={turn.text} />
                  </Message.Actions>
                ) : null}
              </Message.Content>
            </Message>
          );
        })}
      </div>
      <Prompt onSubmit={send} status={status} onStop={() => (window.clearTimeout(timer.current), setStatus('idle'))}>
        <Prompt.Field>
          <Prompt.Textarea placeholder="Ask something" />
          <Prompt.Footer>
            <Prompt.Tools />
            <Prompt.Submit />
          </Prompt.Footer>
        </Prompt.Field>
      </Prompt>
    </div>
  );
}

API Reference

Message

PropTypeDefaultDescription
fromMessageFromassistantWho wrote it. A person's turn is a bubble on the trailing side; the assistant's is prose.
streamingboolean—Still being written: a caret follows the last word and the turn is marked busy.

Message.Group

PropTypeDefaultDescription
fromMessageFromassistantWho wrote every turn in it.

Message.Actions

PropTypeDefaultDescription
reveal'always' | 'hover'alwaysalways, the default, for the latest answer. hover for earlier ones: hidden until the turn is hovered or focused, and always shown on touch.

Message.Action

PropTypeDefaultDescription
labelstring—Said by a screen reader and shown in the tooltip. Required, since the button is an icon.
copystring | (() => string)—Text to put on the clipboard when pressed. The icon turns to a check for a moment, and the label to "Copied".
pressedboolean—For a toggle such as a thumbs-up: whether it is on.

Message.Branch

PropTypeDefaultDescription
countnumber—How many versions of the answer there are.
indexnumber—Which one is shown, from 0.
onIndexChange(index: number) => void—Called with the index of the version asked for.

Message.Attachment

PropTypeDefaultDescription
namestring—The file's name.
srcstring—A picture to show in place of the file's card.
metastring—A line under the name: its type and size.
iconReactNode—The glyph on a file's card. A page outline when left out.

Every part also takes the props of the element it renders, and a className.

Notes

Accessibility

A streaming turn is aria-busy, so a screen reader reads it once it is finished rather than a word at a time. Message.Status is a role="status". Each action has its label as its accessible name, and Message.Actions is a toolbar named "Message actions".

Reduced motion

With reduced motion turned on, the caret does not pulse and the status line does not shimmer.

Rendering Markdown

Message.BubbleContent styles the HTML that Markdown renders to: p, ul, ol, li, strong, a, code, pre and h3. Render the Markdown with the library you already use and put the result inside it.

Server Components

Message is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read Message.Group off the import, so each part is also exported by name: MessageGroup is Message.Group, and so on for the others.

import { Message, MessageGroup, MessageAvatar, MessageContent, MessageHeader, MessageBubble, MessageBubbleContent, MessageFooter, MessageActions, MessageAction, MessageBranch, MessageStatus, MessageAttachments, MessageAttachment } from "@/components/panelui/message"

On this page