Message Scroller
Follows new content, keeps a scrolled-up reader in place
Conversation
MessageScroller is the transcript. It follows new rows and a growing streamed reply while the reader is at the end; the moment they scroll away it stops, keeps their position and offers the way back with the number of rows that arrived unseen.
Installation
nub add @gpuixui/componentsUsage
import {
MessageScroller,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerJumpToLatest,
MessageScrollerViewport,
} from "@gpuixui/components";
<MessageScroller label="Conversation" streaming={streaming}>
<MessageScrollerViewport style={{ height: 320 }}>
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem key={message.id}>
<text>{message.body}</text>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerJumpToLatest />
</MessageScroller>;Composition
MessageScroller
├── MessageScrollerViewport
│ └── MessageScrollerContent
│ └── MessageScrollerItem
└── MessageScrollerJumpToLatestThe height contract
The viewport is the flexible middle of the scroller: it grows, may shrink and never makes the frame taller. The jump control floats over the viewport's bottom edge, centred, so showing it does not take height from the transcript or move anything below the scroller. The root is the positioned box that the control resolves against. Give the scroller a bounded height (directly, or through a sized flex parent) because a viewport with no height has nothing to scroll.
The viewport carries no inset of its own: put the transcript's padding on
MessageScrollerContent (or the rows), so the scrollbar sits at the edge and
the first and last row keep their inset.
Following and releasing
- A reader inside
threshold(default 24px) of the tail is at the end. New rows and a reply that grows keep the tail visible. - Scrolling away releases the view: appends and growth change nothing, and the floating control counts the rows that arrived.
- Jump to latest returns to the end and re-pins.
- A row that grows through its own state is still followed while pinned: the
item's own
onLayoutsize report is the measurement, so no React render is needed. - A sibling that re-renders does not move the tail: the follow rule depends on the row count and on measured sizes, never on a plain render.
- An observation the scroller cannot read (
NaN) is ignored; the pin state is kept.
Turn anchoring
MessageScrollerItem scrollAnchor places a newly mounted row near the top with
previousItemPeek of the row above still visible, but only while the reader is
pinned. It is opt-in: a Chat that appends a user row and a reply should mark the
user row as the anchor if it wants shadcn's turn behaviour. An unpinned reader
is never moved.
API
| Part | Renders | Props |
|---|---|---|
MessageScroller | role="group" named by label | label (required), threshold?, previousItemPeek?, pinned?, defaultPinned?, onPinnedChange?, streaming?, className?, children |
MessageScrollerViewport | the scroll container and its tail sentinel | style, onScroll?, onLayout?, ref?, className?, children |
MessageScrollerContent | the rows column; publishes aria-busy | className?, children |
MessageScrollerItem | one row, no surface; reports its size | scrollAnchor?, className?, children, ref? |
MessageScrollerJumpToLatest | a floating secondary control, centred over the viewport's bottom edge | label?, className?, style? |
Edit this page on GitHubpackages/components/docs/message-scroller.mdx