Skip to documentation content

Message Scroller

Follow streamed replies and revisit earlier messages with a preview navigation rail.

message-scroller-demo

Overview

Message Scroller keeps new output in view while the reader stays near the end of a conversation. Scrolling back pauses following, so growing replies do not pull the reader away from an earlier message.

Set navigation="rail" to add message ticks on the right. Hover or focus a tick to preview its message, then activate it to scroll there. The rail appears only when the transcript overflows and contains at least two marked messages.

Use this component when you need control over the transcript's layout and navigation. Chat Thread includes a footer slot for the composer; Message Scroller leaves the composer layout to your application. Both can render Chat Message rows.

Behavior

Following output

Following starts enabled. The default live-edge threshold is 56 pixels from the end. Scrolling beyond that threshold pauses following; returning near the end resumes it. Selecting the last rail tick also resumes following. Set followOutput={false} to disable automatic following entirely.

onFollowChange reports when the reader leaves or returns to the live edge. It does not load messages or control your model connection.

Message markers

The rail finds descendants with data-slot="message". Add data-from="user" or data-from="assistant" to name each row in navigation labels and pair user-message previews with the following assistant reply.

Wrap just the message text in data-slot="message-content" or data-slot="message-bubble-content". This keeps author labels and action text out of previews. Without that wrapper, the component uses the whole row's text content.

Message additions, streamed text, and size changes refresh the rail. Give the scroller a bounded height, such as h-96, or place it in a flex layout with a constrained height and min-h-0.

Your application's responsibilities

Pass rendered messages as children, keep message keys stable, and supply loading and error content. busy marks the transcript as receiving updates; it does not draw a loading indicator. Put your composer outside the scroller so it stays available while the transcript scrolls.

Accessibility

The transcript is a keyboard-focusable section containing a polite live log. Set label to name the conversation and navigationLabel to name the rail. Set busy while a streamed response is incomplete. Reduced-motion preferences disable smooth scrolling and the rail's animated transitions.

Rail ticks use native buttons with names that identify the sender and message position. Keyboard focus shows the same preview as hover. A touch selects a message and pins its preview; a tap outside clears that pinned preview.

KeyAction
TabMove between the transcript and rail controls
Arrow Up, Arrow Down, Page Up, Page DownScroll the focused transcript using browser controls
Enter or SpaceActivate a focused rail button
EscapeClear a touch-pinned preview

Installation

npx honestui@latest add https://ai.honestui.com/r/message-scroller.json

Usage

import { MessageScroller } from "@/components/ai/message-scroller"

<MessageScroller
  label="Conversation"
  navigation="rail"
  busy={isStreaming}
  className="h-96"
  contentClassName="flex flex-col gap-5 p-5"
>
  {messages.map((message) => (
    <article
      key={message.id}
      data-slot="message"
      data-from={message.role}
      aria-label={message.role === "user" ? "You" : "Assistant"}
    >
      <div data-slot="message-content">{message.text}</div>
    </article>
  ))}
</MessageScroller>

The Generative UI playground uses this scroller with its composer fixed below the transcript.

API reference

MessageScroller

Accepts native div props, including children, in addition to those listed.

PropTypeDefaultDescription
followOutputbooleantrueFollow growing content while the reader remains near the end.
followThresholdnumber56Distance from the end, in pixels, that counts as following.
smoothbooleantrueUse smooth automatic scrolling unless reduced motion is requested.
onFollowChange(following: boolean) => voidNoneReports leaving or returning to the live edge.
labelstring"Conversation"Accessible name of the scrollable section.
busybooleanNoneSets aria-busy on the transcript log.
navigation"rail"NoneEnable the preview navigation rail. Otherwise use a native scrollbar.
navigationLabelstring"Message navigation"Accessible name of the navigation rail.
viewportClassNamestringNoneClasses for the scrolling section.
contentClassNamestringNoneClasses for the transcript log.
railClassNamestringNoneClasses for the rail navigation.
viewportRefRef<HTMLElement>NoneRef to the scrolling section; supports React 19 callback cleanup.
viewportPropssection propsNoneNative section props except children, className, and ref.
contentPropsdiv propsNoneNative log props except children, className, and ref.

PreviewRail

Exported from the same file for standalone section navigation. items is required; each item has id and label, with optional ariaLabel, description, href, target, and rel.

PropTypeDefaultDescription
itemsPreviewRailItem[]RequiredNavigation items and preview content.
labelstring"Section navigation"Accessible name of the navigation.
orientation"vertical" | "horizontal""vertical"Direction of the tick rail.
activeIdstringNoneControlled active item.
defaultActiveIdstringFirst itemInitial active item in uncontrolled use.
onActiveChange(id: string) => voidNoneReports selection.
onItemSelect(item: PreviewRailItem) => voidNoneHandles item activation.
renderPreview(item: PreviewRailItem) => ReactNodeNoneReplaces the default preview card.
showPreviewbooleantrueShow preview cards.
previewSide"before" | "after""after"Side of the vertical rail used for previews.
highlightActivebooleanfalseHighlight the selected tick when no preview is displayed.
itemSizenumber24Space per item along the rail, in pixels.
childrenReactNodeNoneContent placed beside the rail.
classNamestringNoneClasses for the outer layout.
railClassNamestringNoneClasses for the navigation.
previewContainerClassNamestringNoneClasses for the preview-positioning grid.
previewClassNamestringNoneClasses for the preview wrapper.

For standalone items with href, the first touch previews the link and the second follows it. Mouse and keyboard activation follow the link directly. Custom preview content is visually displayed with aria-hidden="true"; keep essential information in the item label or linked destination.