ComponentsAgent patterns

Message Scroller

The scrolling column of a conversation. It opens at the newest message, pins each new turn under the top edge so its reply reads from the start, keeps your place when older messages load, and shows a jump button when there is more history below.

Usage

A conversationTSX
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@tulipfarm/sprout/message-scroller";

<div className="h-96 overflow-hidden rounded-lg border">
  <MessageScrollerProvider>
    <MessageScroller>
      <MessageScrollerViewport>
        <MessageScrollerContent>
          {messages.map((message, index) => (
            <MessageScrollerItem
              key={message.id}
              messageId={message.id}
              scrollAnchor={index === messages.length - 1}
            >
              <div className="p-4">
                <Message align={message.author === "human" ? "end" : "start"}>
                  <MessageContent>
                    <MessageBubble variant={message.author === "human" ? "default" : "secondary"}>
                      {message.text}
                    </MessageBubble>
                  </MessageContent>
                </Message>
              </div>
            </MessageScrollerItem>
          ))}
        </MessageScrollerContent>
      </MessageScrollerViewport>
      <MessageScrollerButton aria-label="Scroll to newest message" />
    </MessageScroller>
  </MessageScrollerProvider>
</div>

MessageScroller fills its parent, so give the parent a height.MessageScrollerContent spaces items 24px apart; pad inside each item. Give every item a stable messageId. Set scrollAnchor on the turn that should be pinned when it is added, usually the newest message the reader sent. A pinned turn leaves 64px of the message before it showing (scrollPreviousItemPeek).

Streaming replies

Follow the reply while at the endTSX
<MessageScrollerProvider autoScroll>
  <MessageScroller>…</MessageScroller>
  <Composer />
</MessageScrollerProvider>

With autoScroll, growing content keeps the view at the end as long as the reader is there. Scrolling up with the wheel, touch, or keys stops following until they come back to the end. Without it, the sent turn is still pinned and the reply grows below it.

Jump and read position

HooksTSX
const { scrollToMessage, scrollToStart, scrollToEnd } = useMessageScroller();
const { start, end } = useMessageScrollerScrollable();
const { visibleMessageIds, currentAnchorId } = useMessageScrollerVisibility();

scrollToMessage("m8", { align: "center", behavior: "smooth" });

Call the hooks anywhere inside MessageScrollerProvider.align is "start" (the default),"center", "end", or "nearest";behavior is "auto" (the default) or"smooth". Each returns false when it can’t scroll, such as for an unknown id; a jump requested before any message renders waits for it. Visibility observes items only while a component reads it.

Opening position

defaultScrollPosition is "end" (the default),"start", or "last-anchor", which pins the lastscrollAnchor item. The history stays hidden until it is in place, so it never flashes at the top first.

Jump button

MessageScrollerButton renders a SprõutButton, secondary and icon-sm by default, and passes variant and size through.direction="start" moves it to the top and turns the arrow up. Pass children for a labeled button, or render for your own element.

Loading earlier messages

preserveScrollOnPrepend on the viewport is on by default: when items are added above, the view moves by the same amount so the message you are reading stays still. Turn it off to see the jump.

States

Put the empty, loading, or error content insideMessageScrollerContent so the frame keeps its size. Mark the viewportaria-busy while loading.

Accessibility

The viewport is a focusable region named “Messages” unless you passaria-label, so arrow keys, Page Up, Page Down, Home, and End scroll it. The content is a polite log that announces new messages. A hidden jump button is inert and out of the tab order; icon-only buttons read “Scroll to end” or “Scroll to start” unless you pass aria-label. The button fades and slides in over 180ms. With reduced motion set it only fades, and its scroll is instant.