Message Scroller
A transcript scroller for streaming conversations. It follows new messages while the reader is at the bottom, stops when they scroll away, and holds their position when older history loads above.
The scroller fills its parent, so give it a height-constrained container. It needs no card or frame of its own. See Chat for the full-screen layout.
Sending shows the typing indicator, then the reply fades and rises in. Scroll up first and the position holds instead. Loading older messages never moves the row you are reading, and removing one animates out before it goes.
Usage
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@e-infra/design-system"
<div className="h-96">
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem key={message.id} messageId={message.id}>
<Message align={message.role === "user" ? "end" : "start"}>
{/* … */}
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>Behaviour
| Behaviour | What happens |
|---|---|
| Follow the live edge | While the viewport sits at the bottom, appended content scrolls into view automatically. |
| Stop on reader scroll | A wheel, touch drag, or scroll key that moves away from the bottom stops the following. |
| Re-engage | Scrolling back to the bottom resumes following. |
| Preserve position | Content added above the reader shifts scrollTop to match, so the row they are on stays fixed. |
| Scroll affordance | MessageScrollerButton fades in only when there is content to scroll toward. |
| Animate new arrivals | Rows added after the first paint fade and rise in. The opening backlog does not animate. |
Only reader-driven scrolling switches following off; the component's own scrolling never does. Otherwise a streamed reply would cancel its own autoscroll.
Streaming state
Pass busy to MessageScrollerContent while a reply is streaming. It sets aria-busy, which tells screen readers the log is still being written.
<MessageScrollerContent busy={status === "streaming"}>
{/* … */}
</MessageScrollerContent>Entrance animation
Rows that arrive after the first paint fade and rise into place. Rows present on the first render, such as the backlog of a reopened thread, appear immediately, so opening a long transcript does not cascade.
The animation moves only opacity and transform, never layout, so it cannot
interfere with autoscroll or position preservation. It is skipped under
prefers-reduced-motion.
Use animateOnEnter to override it per row — for example, off on a row you replace in
place while streaming.
<MessageScrollerItem messageId={message.id} animateOnEnter={false}>Removing a message
A row cannot animate out after React unmounts it. Mark it exiting, then remove it
from your list when onExited fires.
const [removing, setRemoving] = useState<string | null>(null)
<MessageScrollerItem
messageId={message.id}
exiting={removing === message.id}
onExited={() => {
setMessages((current) => current.filter((m) => m.id !== message.id))
setRemoving(null)
}}
>onExited fires exactly once, whether or not the animation runs. A deadline backs it,
so a row is never stranded when motion is reduced or the animation event is missed.
The row keeps its height while fading, then collapses when it is removed. Animating the height instead would move every row below it mid-scroll.
Scroll commands
useMessageScroller returns imperative commands. It must be called from inside a MessageScrollerProvider.
const { scrollToEnd, scrollToStart, scrollToMessage } = useMessageScroller();
scrollToEnd(); // jump to the latest message
scrollToMessage("msg-42", { align: "center" }); // reveal a specific messageuseMessageScrollerScrollable reports which edges are still reachable, for your own scroll affordances.
const { start, end } = useMessageScrollerScrollable();Loading older messages
Prepend to your message array and the reader's position is preserved. preserveScrollOnPrepend is on by default.
<MessageScrollerViewport preserveScrollOnPrepend={false}>Set it to false only when you want prepends to move the viewport.
Components
| Component | Description |
|---|---|
MessageScrollerProvider | Owns scroll state. Renders no DOM. |
MessageScroller | Positioning frame for the viewport and controls |
MessageScrollerViewport | The scrollable element. Owns scroll events and prepend handling. |
MessageScrollerContent | Transcript container, announced as a live region |
MessageScrollerItem | One transcript row — a message, marker, or separator |
MessageScrollerButton | Scroll-to-edge control, inert when that edge is already reached |
Props
MessageScrollerProvider
| Prop | Type | Default | Description |
|---|---|---|---|
| autoScroll | boolean | true | Follow appended content while parked at the live edge |
| defaultScrollPosition | start | end | end | Opening position, applied once on the first non-empty render |
| scrollEdgeThreshold | number | 8 | Pixels from an edge that still count as being at that edge |
MessageScrollerViewport
| Prop | Type | Default | Description |
|---|---|---|---|
| preserveScrollOnPrepend | boolean | true | Hold the reader's row when content loads above |
| className | string | - | Additional CSS classes |
| ...props | React.HTMLAttributes | - | Native div props |
MessageScrollerContent
| Prop | Type | Default | Description |
|---|---|---|---|
| busy | boolean | false | Sets aria-busy while a reply is streaming |
| className | string | - | Additional CSS classes |
| ...props | React.HTMLAttributes | - | Native div props |
MessageScrollerItem
| Prop | Type | Default | Description |
|---|---|---|---|
| messageId | string | - | Stable row id, required for scrollToMessage |
| animateOnEnter | boolean | - | Force the entrance animation on or off for this row |
| className | string | - | Additional CSS classes |
| ...props | React.HTMLAttributes | - | Native div props |
MessageScrollerButton
| Prop | Type | Default | Description |
|---|---|---|---|
| direction | start | end | end | Which edge the button scrolls toward |
| behavior | ScrollBehavior | smooth | Native scroll behavior |
| variant | Button variant | outline | Passed through to Button |
| size | Button size | icon | Passed through to Button |
Accessibility
The viewport is a focusable role="region" labelled "Messages", so keyboard users can scroll the transcript with arrows, Page Up/Down, and Home/End. The content is a role="log" with aria-relevant="additions", so assistive technology announces new messages rather than re-reading the transcript. The scroll button is inert and aria-hidden when its edge is already reached, keeping it out of the tab order.
Differences from shadcn/ui
This is an e-INFRA implementation rather than a port. The part and prop names match, so the shadcn documentation transfers, but the scroll engine is our own: the upstream behaviour package requires React 19, while this library supports React 18 and 19.
Not implemented: defaultScrollPosition="last-anchor", the scrollAnchor prop and its anchored-streaming mode, and useMessageScrollerVisibility.