Skip to main content
Urbicon UI
source

ChatMessageListexperimental

A scrollable conversation log that follows streaming content while the reader is at the bottom, and lets them scroll up through history without being pulled back down. Screen readers hear the generation start and the settled answer once, not every token.

Playground

Press Append while scrolled to the bottom: the list follows the new message. Scroll up first and append: following pauses (the badge flips to paused) and a jump-back pill shows how many messages arrived. Click the pill to resume. The live playground shows streaming, tool calls, and citations together.

following

Question 1: how does the follow-scroll behave?

Copy

Answer 1: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.

Copy

Question 2: how does the follow-scroll behave?

Copy

Answer 2: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.

Copy

Question 3: how does the follow-scroll behave?

Copy

Answer 3: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.

Copy

Question 4: how does the follow-scroll behave?

Copy

Answer 4: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.

Copy

Question 5: how does the follow-scroll behave?

Copy

Answer 5: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.

Copy

Question 6: how does the follow-scroll behave?

Copy

Answer 6: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.

Copy
Layout
<script lang="ts">
  import { ChatMessageList } from '@urbicon-ui/blocks';

  let messages = $state([
    {
      id: 'seed-1-q',
      role: 'user',
      parts: [{ type: 'text', text: 'Question 1: how does the follow-scroll behave?' }],
      status: 'complete'
    },
    {
      id: 'seed-1-a',
      role: 'assistant',
      parts: [
        { type: 'text', text: 'Answer 1: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.' }
      ],
      status: 'complete'
    },
    {
      id: 'seed-2-q',
      role: 'user',
      parts: [{ type: 'text', text: 'Question 2: how does the follow-scroll behave?' }],
      status: 'complete'
    },
    {
      id: 'seed-2-a',
      role: 'assistant',
      parts: [
        { type: 'text', text: 'Answer 2: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.' }
      ],
      status: 'complete'
    },
    {
      id: 'seed-3-q',
      role: 'user',
      parts: [{ type: 'text', text: 'Question 3: how does the follow-scroll behave?' }],
      status: 'complete'
    },
    {
      id: 'seed-3-a',
      role: 'assistant',
      parts: [
        { type: 'text', text: 'Answer 3: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.' }
      ],
      status: 'complete'
    },
    {
      id: 'seed-4-q',
      role: 'user',
      parts: [{ type: 'text', text: 'Question 4: how does the follow-scroll behave?' }],
      status: 'complete'
    },
    {
      id: 'seed-4-a',
      role: 'assistant',
      parts: [
        { type: 'text', text: 'Answer 4: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.' }
      ],
      status: 'complete'
    },
    {
      id: 'seed-5-q',
      role: 'user',
      parts: [{ type: 'text', text: 'Question 5: how does the follow-scroll behave?' }],
      status: 'complete'
    },
    {
      id: 'seed-5-a',
      role: 'assistant',
      parts: [
        { type: 'text', text: 'Answer 5: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.' }
      ],
      status: 'complete'
    },
    {
      id: 'seed-6-q',
      role: 'user',
      parts: [{ type: 'text', text: 'Question 6: how does the follow-scroll behave?' }],
      status: 'complete'
    },
    {
      id: 'seed-6-a',
      role: 'assistant',
      parts: [
        { type: 'text', text: 'Answer 6: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.' }
      ],
      status: 'complete'
    }
  ]);
</script>

<ChatMessageList
  {messages}
  layout="bubble"
/>

01 Examples

Streaming append

Append to the messages array, or grow the last message's text part as chunks arrive, and the list follows while the reader is at the bottom. onStickChange fires when following breaks, so you can show your own state, such as a 'following / paused' badge.
<script lang="ts">
  import { ChatMessageList, type ChatMessageData } from '@urbicon-ui/blocks';

  let messages = $state<ChatMessageData[]>(history);
  let following = $state(true);

  function streamChunk(id: string, chunk: string) {
    messages = messages.map((m) =>
      m.id === id
        ? { ...m, parts: [{ type: 'text', text: (m.parts[0]?.text ?? '') + chunk }] }
        : m
    );
  }
</script>

<ChatMessageList
  {messages}
  onStickChange={(stuck) => (following = stuck)}
/>

Load older history (prepend anchor)

Prepending older messages at the top would normally jump the viewport. The list detects the prepend and holds the scroll position, so the message you were reading stays in place as history loads above it.
<script lang="ts">
  import { ChatMessageList, type ChatMessageData } from '@urbicon-ui/blocks';

  let messages = $state<ChatMessageData[]>(recent);
  let loading = $state(false);

  async function loadOlder() {
    if (loading) return;
    loading = true;
    const older = await fetchOlderPage(); // resolves to ChatMessageData[]
    messages = [...older, ...messages]; // prepend — the anchor holds your place
    loading = false;
  }
</script>

<div class="flex flex-col">
  <button onclick={loadOlder} disabled={loading}>Load older messages</button>
  <ChatMessageList {messages} />
</div>

Custom per-message rendering

The message snippet overrides how each entry renders. It receives message, index, and isLast. Use it for date separators, custom system notices, or a different bubble, and fall back to the default ChatMessage for the rest.
<ChatMessageList {messages}>
  {#snippet message({ message, isLast })}
    {#if message.role === 'system'}
      <div class="text-center text-xs text-text-tertiary">{message.parts[0]?.text}</div>
    {:else}
      <ChatMessage
        {message}
        onRegenerate={isLast && message.role === 'assistant' ? () => regenerate(message.id) : undefined}
      />
    {/if}
  {/snippet}
</ChatMessageList>

02 Scroll engine

The list adjusts the scroll position itself rather than through CSS overflow-anchor, which Safari does not support. Four behaviours follow from that:

  • Follow while at the bottom. New content keeps the viewport pinned to the latest message.
  • Upward scroll breaks the follow. When you scroll away from the bottom, the list stops following and shows a floating jump-back pill with the count of new messages.
  • Proximity re-stick. Scroll back near the bottom (or click the pill) and following resumes. onStickChange fires on every flip.
  • Prepend anchoring. When older messages are added to the front, the current message stays in place instead of jumping.

Note: rest attributes (including a raw onscroll) land on the non-scrolling root, so observe follow-state through onStickChange rather than a scroll listener.

03 Accessibility

Why the log is aria-live="off"

The messages render inside a role="log" region, but its live channel is off. A streaming answer changes the DOM dozens of times a second; a polite or assertive log would announce every token, so the log stays silent.

The separate status region

Announcements come instead from a visually hidden role="status" region that carries only the meaningful transitions: generatingLabel once when an assistant message starts streaming, and the settled answer once when it completes (or errorLabel / abortedLabel for a failed stream). Screen-reader users hear "generating…", then the final answer, rather than each token.

Scrollable region is focusable

The viewport is a labelled role="region" (listLabel) with tabindex="0", so keyboard users can focus the conversation and scroll it with the arrow / Page keys. Its focus ring uses focus-visible: (keyboard-only).

The jump-back button

The floating pill is a real <button> whose aria-label carries the pending count (newMessagesLabel) or falls back to scrollToBottomLabel when nothing is pending, so its purpose is announced rather than implied by an icon.

04 API Reference

23 props
23 props 1 required
Prop
Type
Default
Description

05 Types

Local type definitions used by this component.

15 types
Name
Kind
Category
Used by
Description

06 Installation

Import

import { ChatMessageList } from '@urbicon-ui/blocks';
import type { ChatMessageData, ChatMessageListItemContext } from '@urbicon-ui/blocks';