Components

Chat message

A message with its sender, time, status and actions: the user’s in a bubble, an answer as text, a sender’s run grouped, and a streamed body that never re-wraps.

layout
bubble
content
scheme
direction

Basic #

Which sizes does the ladder have?
Ada
Two ends, 36px and 28px, with the step knob between them.
export const ChatMessageStyle = forSlots(ChatMessageSlots)(Recipes.ChatMessage({}))
export const ChatMessageGroupStyle = forSlots(ChatMessageGroupSlots)(Recipes.ChatMessageGroup({}))
const ChatAvatarStyle = forSlots(AvatarSlots)(Recipes.Avatar({ size: 'sm' }))

/** A conversation's column, as a chat pane would give it. */
const chatColumn = <Message>(h: HtmlBuilder<Message>, content: Html): Html =>
  h.div([h.Style({ inlineSize: 'min(32rem, 100%)' })], [content])

// A question and its answer. What each action sends is the application's:
// copy runs a clipboard Command, and a toggle such as "Good answer" is
// `pressed` from the Model.
export const chatMessage = <Message>(
  style: NamedStyle<typeof ChatMessageSlots>,
  liked: boolean,
  messages: { readonly onCopy: Message; readonly onLike: Message },
  h: HtmlBuilder<Message>,
): Html =>
  chatColumn(
    h,
    ChatMessage.group(
      {
        items: [
          {
            key: 'question',
            from: 'user',
            content: 'Which sizes does the ladder have?',
            time: { at: '2026-10-17T09:30', label: '9:30' },
          },
          {
            key: 'answer',
            from: 'assistant',
            author: 'Ada',
            avatar: Avatar.view({ name: 'Ada Lovelace', style: ChatAvatarStyle }, h),
            content: 'Two ends, 36px and 28px, with the step knob between them.',
            actions: [
              { key: 'copy', label: 'Copy', onClick: messages.onCopy },
              { key: 'like', label: 'Good answer', onClick: messages.onLike, pressed: liked },
            ],
            pinActions: true,
          },
        ],
        style: ChatMessageGroupStyle,
        messageStyle: style,
      },
      h,
    ),
  )

Status and retry #

Book the room for Friday.
Sent
And invite Grace.
Not sent
Thanks!
Sending…
// A message the server refused offers a retry; one still on its way says so.
export const chatMessageStatus = <Message>(
  style: NamedStyle<typeof ChatMessageSlots>,
  retried: boolean,
  onRetry: Message,
  h: HtmlBuilder<Message>,
): Html =>
  chatColumn(
    h,
    ChatMessage.group(
      {
        items: [
          { key: 'sent', from: 'user', content: 'Book the room for Friday.', status: 'sent' },
          retried
            ? { key: 'failed', from: 'user', content: 'And invite Grace.', status: 'sent' }
            : {
                key: 'failed',
                from: 'user',
                content: 'And invite Grace.',
                status: 'failed',
                onRetry,
              },
          { key: 'sending', from: 'user', content: 'Thanks!', status: 'sending' },
        ],
        style: ChatMessageGroupStyle,
        messageStyle: style,
      },
      h,
    ),
  )

A thread #

Grace
Deploy is green.
Grace
Shipping at noon.
Me
Great, thanks.
export const ChatThreadStyle = forSlots(ChatMessageSlots)(Recipes.ChatMessage({ layout: 'thread' }))

// A channel: everyone at the start beside their picture, and a sender's next
// messages continued under the first.
export const chatMessageThread = <Message>(
  style: NamedStyle<typeof ChatMessageSlots>,
  h: HtmlBuilder<Message>,
): Html => {
  const person = (name: string) => Avatar.view({ name, style: ChatAvatarStyle }, h)
  return chatColumn(
    h,
    ChatMessage.group(
      {
        items: [
          {
            key: 'deploy',
            from: 'assistant',
            author: 'Grace',
            avatar: person('Grace Hopper'),
            content: 'Deploy is green.',
            time: { at: '2026-10-17T10:02', label: '10:02' },
          },
          {
            key: 'noon',
            from: 'assistant',
            author: 'Grace',
            avatar: person('Grace Hopper'),
            content: 'Shipping at noon.',
          },
          {
            key: 'thanks',
            from: 'user',
            author: 'Me',
            avatar: person('Me'),
            content: 'Great, thanks.',
          },
        ],
        style: ChatMessageGroupStyle,
        messageStyle: style,
      },
      h,
    ),
  )
}

Streaming #

Ada

The ladder has two ends, 36

export const ANSWER =
  'The ladder has two ends, 36 pixels and 28, and the step knob moves every control between them at once, so a region set compact shrinks its buttons, fields and rows together.'.split(
    ' ',
  )

// The same message drawn again as each word arrives: its body wraps greedily,
// so the words already shown stay on their lines, and it is aria-busy until
// the answer is whole. Here a press brings the next words; in an application,
// a stream does.
export const chatMessageStreaming = <Message>(
  style: NamedStyle<typeof ChatMessageSlots>,
  shown: number,
  onMore: Message,
  h: HtmlBuilder<Message>,
): Html => {
  const done = shown >= ANSWER.length
  return h.div(
    [
      h.Style({
        display: 'grid',
        gap: '0.75rem',
        justifyItems: 'start',
        inlineSize: 'min(32rem, 100%)',
      }),
    ],
    [
      ChatMessage.view(
        {
          from: 'assistant',
          author: 'Ada',
          content: h.p([], [ANSWER.slice(0, shown).join(' ')]),
          streaming: !done,
          style,
        },
        h,
      ),
      Button.view(
        { label: done ? 'Start over' : 'Next words', onClick: onMore, style: ButtonStyle },
        h,
      ),
    ],
  )
}

Usage

Each example above names the function that drew it, over the theme module Create a theme writes. The preview passes it the style picked above instead of its default.

The recipe ↗This code ↗