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
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
<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
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.