MessageScroller

Scroll behaviour a chat transcript needs.

Pins to the bottom while a reply streams — but only while you are already there.

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

Usage

<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

PropTypeDefaultDescription
classNamestring
autoScrollbooleanfalseFollow 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.
preserveScrollOnPrependbooleantrueKeep the reader on the same message when older ones are added above. Without it, loading history throws them a screen backwards.
defaultScrollPositionMessageScrollerPosition'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

PropTypeDefaultDescription
classNamestring

MessageScroller.List

PropTypeDefaultDescription
classNamestring
contentContainerClassNamestringClasses on the virtualized list's padded transcript column.
datareadonly 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 | nullDraw one turn from the native list window.

MessageScroller.Content

PropTypeDefaultDescription
classNamestring

MessageScroller.Item

PropTypeDefaultDescription
classNamestring
messageIdstringStable id for this turn. scrollToMessage takes the same value.
scrollAnchorbooleanfalseMarks 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

PropTypeDefaultDescription
classNamestring
target'start' | 'end''end'end jumps to the newest message, start to the beginning of the thread.
accessibilityLabelstring

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

On this page