MessageScroller
Scroll behaviour a chat transcript needs.
The scroll behaviour a chat transcript needs.
A transcript is the one list where the interesting end is the bottom, the content grows while you are reading it, and history is added to the top. A plain scroll view gets all three wrong: it opens at the top, it stays put while a reply streams in below the fold, and it jumps a screen when older messages load.
One rule sits behind all of it — the reader's position is theirs. New content follows the bottom only while they are already at the bottom, scrolling away hands control back to them until they ask for it, and content added above them never moves what they are looking at.
For long or streaming threads, MessageScroller.List is the virtualized path: it renders a bounded native list window instead of mounting the whole transcript.
Installation
MessageScroller ships with the library — no separate install.
import { MessageScroller, Message, Avatar, Marker, Button, useMessageScroller, useMessageScrollerVisibility } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add message-scrollerUsage
<MessageScroller autoScroll className="flex-1">
<MessageScroller.List
data={messages}
renderItem={({ item }) => <Message>{item.body}</Message>}
/>
<MessageScroller.Button />
</MessageScroller>Composition
<MessageScroller>
<MessageScroller.Viewport>…</MessageScroller.Viewport>
<MessageScroller.List>…</MessageScroller.List>
<MessageScroller.Content>…</MessageScroller.Content>
<MessageScroller.Item>…</MessageScroller.Item>
<MessageScroller.Button>…</MessageScroller.Button>
</MessageScroller>MessageScroller.Viewport— The backward-compatible ScrollView composition for short or structurally mixed transcripts. It mounts every child; prefer List for long or streaming threads.MessageScroller.List— The virtualized transcript. Takes data whose rows have stable messageId values, renders only a bounded native window, and owns follow, prepend retention, anchoring, and jump events.MessageScroller.Content— The transcript column. Announces additions rather than the whole list.MessageScroller.Item— One turn. It exists to be measured — without a boundary per turn there is nothing to scroll to and nothing to measure a prepend against.MessageScroller.Button— The way back to the live edge, shown only when there is one to go back to.
Examples
Following a streamed reply
autoScroll pins the viewport to the bottom as the reply grows — but only while the reader is already there. Scrolling up mid-stream disengages it, and MessageScroller.Button is how they opt back in.
<MessageScroller autoScroll className="flex-1">
<MessageScroller.Viewport>
<MessageScroller.Content>
{turns.map((turn) => (
<MessageScroller.Item
key={turn.id}
messageId={turn.id}
scrollAnchor={turn.role === "user"}
>
<Turn turn={turn} />
</MessageScroller.Item>
))}
{streaming ? (
<Marker>
<Marker.Content shimmer>Generating…</Marker.Content>
</Marker>
) : null}
</MessageScroller.Content>
</MessageScroller.Viewport>
<MessageScroller.Button />
</MessageScroller>Loading history without jumping
Prepend older turns and the reader stays on the message they were reading. The shift is measured on a message they can already see rather than taken from the height delta, so it stays correct when a message is prepended and another edits itself in the same commit.
{/* On by default — pass preserveScrollOnPrepend={false} to opt out. */}
<MessageScroller className="flex-1">
<MessageScroller.Viewport>
<MessageScroller.Content>
<Button variant="ghost" size="sm" onPress={loadOlder}>
Load older messages
</Button>
{turns.map((turn) => (
<MessageScroller.Item key={turn.id} messageId={turn.id}>
<Turn turn={turn} />
</MessageScroller.Item>
))}
</MessageScroller.Content>
</MessageScroller.Viewport>
</MessageScroller>Opening a saved thread
last-anchor opens on the last turn that started something, rather than at the bottom of whatever the reply to it happened to be. Mark those turns with scrollAnchor — usually the user's.
<MessageScroller className="flex-1" defaultScrollPosition="last-anchor">
<MessageScroller.Viewport>
<MessageScroller.Content>
{turns.map((turn) => (
<MessageScroller.Item
key={turn.id}
messageId={turn.id}
scrollAnchor={turn.role === "user"}
>
<Turn turn={turn} />
</MessageScroller.Item>
))}
</MessageScroller.Content>
</MessageScroller.Viewport>
{/* Points the other way — back to the top of the thread. */}
<MessageScroller.Button target="start" />
</MessageScroller>Driving it from outside
Two hooks, for anything rendered inside the scroller that needs to move it or read where the reader is. useMessageScrollerVisibility settles after a scroll rather than updating per frame — it is for a header that names the current turn, not for anything animated.
function JumpToLatest() {
const { scrollToEnd, scrollToMessage } = useMessageScroller();
const { currentAnchorId, atEnd } = useMessageScrollerVisibility();
if (atEnd) return null;
return (
<Button size="sm" onPress={() => scrollToEnd()}>
Jump to latest
</Button>
);
}Versions
Following a streamed reply
autoScroll pins to the bottom while a reply streams — but only while you are already there. Scroll up mid-stream and it stops chasing, because a transcript that drags you back down while you are reading is worse than one that does not follow at all.
<MessageScroller autoScroll className="flex-1">
<MessageScroller.Viewport>
<MessageScroller.Content>
{turns.map((turn) => (
<MessageScroller.Item key={turn.id}>
<Turn turn={turn} />
</MessageScroller.Item>
))}
</MessageScroller.Content>
</MessageScroller.Viewport>
<MessageScroller.Button />
</MessageScroller>Loading history
Older turns are prepended. The message being read stays exactly where it is — content added above you must not move you, which is the whole difficulty of an infinite transcript.
<MessageScroller className="flex-1" defaultScrollPosition="end">
<MessageScroller.Viewport>
<MessageScroller.Content>
<Button variant="ghost" size="sm" onPress={loadOlder}>
Load older
</Button>
{turns.map((turn) => (
<MessageScroller.Item key={turn.id}>
<Turn turn={turn} />
</MessageScroller.Item>
))}
</MessageScroller.Content>
</MessageScroller.Viewport>
<MessageScroller.Button />
</MessageScroller>Opening a saved thread
last-anchor opens on the last turn that started something, rather than at the bottom of the reply to it. Reopening a thread at the end of a long answer shows you the end of an answer to a question you can no longer see.
<MessageScroller className="flex-1" defaultScrollPosition="last-anchor">
<MessageScroller.Viewport>
<MessageScroller.Content>
<Marker variant="separator">
<Marker.Content>Yesterday</Marker.Content>
</Marker>
{turns.map((turn) => (
<MessageScroller.Item key={turn.id} anchor={turn.role === "user"}>
<Turn turn={turn} />
</MessageScroller.Item>
))}
</MessageScroller.Content>
</MessageScroller.Viewport>
<MessageScroller.Button target="start" />
</MessageScroller>API Reference
MessageScroller
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
autoScroll | boolean | false | Follow new content down as it arrives — but only while the reader is already at the bottom. Scrolling up disengages it until they come back or press the button. |
preserveScrollOnPrepend | boolean | true | Keep the reader on the same message when older ones are added above. Without it, loading history throws them a screen backwards. |
defaultScrollPosition | MessageScrollerPosition | 'end' | Where a freshly mounted transcript opens. last-anchor is the one to want for a saved thread: it lands on the last turn that started something, rather than at the very bottom of whatever the reply happened to be. |
MessageScroller.Viewport
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
MessageScroller.List
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
contentContainerClassName | string | — | Classes on the virtualized list's padded transcript column. |
data | readonly T[] | — | The complete transcript. Rows outside the native render window stay unmounted; each item therefore carries its stable navigation metadata. |
renderItem | (info: ListRenderItemInfo<T>) => ReactElement | null | — | Draw one turn from the native list window. |
MessageScroller.Content
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
MessageScroller.Item
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
messageId | string | — | Stable id for this turn. scrollToMessage takes the same value. |
scrollAnchor | boolean | false | Marks this row as the start of a turn. defaultScrollPosition="last-anchor" opens on the last one, and it is what a saved thread should land on — the question, not the tail of the answer to it. |
MessageScroller.Button
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
target | 'start' | 'end' | 'end' | end jumps to the newest message, start to the beginning of the thread. |
accessibilityLabel | string | — |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
Choose the real list for long threads. MessageScroller.List takes data items with a stable messageId and optional scrollAnchor, plus a normal FlatList renderItem. It defaults to a 12-row initial render, batches 8 rows, and keeps a 7-viewport window; the underlying FlatList still accepts tuning props. Unlike the compound Viewport / Content / Item path, rows outside that window are not mounted. Keep the compound path for short transcripts that need arbitrary non-row children, and migrate long threads by moving each messageId and scrollAnchor onto its data item.
It needs a bounded height. From flex-1 in a column, or an explicit one. Given an unbounded parent it grows to fit its content and never scrolls at all.
Follow-output is conditional, not automatic. autoScroll means follow the bottom while the reader is at the bottom — never drag them to the bottom. Being pulled away from a message you were half way through reading is the behaviour this exists to avoid.
Prepend retention is native in the virtualized path. maintainVisibleContentPosition keeps the first visible row in place while history arrives above it, including when rows outside the render window have never mounted. Set preserveScrollOnPrepend={false} to opt out.
Every turn needs a stable messageId. It is the data identity for scrollToMessage, initial anchors, and React keys, so an id derived from the array index breaks navigation the moment anything is prepended.
Scroll events are owned. The virtualized path owns onScroll, onContentSizeChange, onViewableItemsChanged, prepend maintenance, and failed-index recovery; the compound viewport owns its four legacy scroll events. These handlers are the behavior, not decoration that a call site can replace.
Public exports
Values: MessageScroller, useMessageScroller, useMessageScrollerVisibility
Types: MessageScrollerProps, MessageScrollerViewportProps, MessageScrollerContentProps, MessageScrollerItemProps, MessageScrollerButtonProps, MessageScrollerPosition