Message Scroller
A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.
New Chat
How can I help you today?
What are we working on today? Press send to start a new conversation
What Makes a Great Streaming Chat Experience
Building a chat interface used to be simple. You create an inverted list with an input. Type a message, it appends at the bottom. When a reply comes in, the list grows and scrolls. Done.
Streaming breaks that model. Messages arrive in chunks while you may still be reading, scrolling, or looking somewhere else entirely.
Now the challenge is preserving the reader's place while the conversation keeps changing. Get that wrong and the experience feels jumpy: people are pulled to the bottom, lose context, and have to find their way back.
In practice, this comes down to scroll: when to follow, when to hold, and when to let the reader decide. A great streaming chat should:
- Move only when the reader asked to move. If someone is reading, don't pull them somewhere else. Auto-scroll should never be the default.
- Follow only while they're following. If they're at the live edge, keep the stream in view. If they scroll away, leave them there.
- Every interaction is a signal. Scrolling is not the only one. Selecting text, using the keyboard, opening a link, or searching should all stop the interface from moving.
- Start a new turn near the top of the viewport. This gives the new turn somewhere it can be read from the beginning.
- Then stream in the answer. The answer should grow into the screen, not immediately push everything away.
- Keep part of the previous conversation in context. The prompt and reply should stay visually connected, and enough of the previous turn should remain visible so the reader knows where they are.
- Let new content arrive offscreen. The conversation can keep streaming without changing what the reader is looking at.
- Show what's happening out of view. Make it clear when a response is still streaming or when new messages have arrived.
- Make it easy to return to the latest reply. A "Jump to latest" action should bring the reader back and resume following.
- Let people jump anywhere in the conversation. Long threads need message links, search, unread markers, and direct navigation.
- Reopen where the reader left off. A saved conversation should open at the last meaningful turn. Often this is the last user message. Not the absolute bottom.
- Keep the reader's place when layout changes. Images load. Markdown expands. Code blocks render. Older messages appear above. None of that should make the reader lose their place.
- Handle interruptions without stealing position. Stopping, retrying, regenerating, branching, or errors should not unexpectedly move the conversation.
- Stay responsive in long threads. Streaming text, markdown, code, images, and long history should still feel responsive.
- Be accessible without the noise. Keep the transcript navigable, preserve keyboard focus, and announce important events at a comfortable pace.
Never move the reader against their intent.
MessageScroller
MessageScroller is a chat transcript scroller built for these behaviors. MessageScrollerProvider owns the scroll state and transcript-row behavior: opening position, streamed output, new-turn anchoring, prepended history, visibility, and scroll controls. MessageScroller is the styled frame that renders inside it.
MessageScroller is scoped to the scroll viewport. It does not own messages, AI state, transport, persistence, branching, or model state. Your product code stays focused on composing messages, markers, tools, attachments, and prompt inputs.
It gives you the scroll behavior that chat needs, without taking over the rest of the chat UI. And it stays fast, even in long conversations with rich markdown.
<script setup>
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller";
</script>
<template>
<MessageScrollerProvider auto-scroll default-scroll-position="last-anchor">
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
<MessageScrollerItem
v-for="message in messages"
:key="message.id"
:message-id="message.id"
:scroll-anchor="message.role === 'user'"
>
<!-- Message, Bubble or Marker goes here -->
</MessageScrollerItem>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton direction="end" />
</MessageScroller>
</MessageScrollerProvider>
</template>The provider must have a constrained height, or a height-bounded parent, so the viewport can scroll. MessageScroller fills its parent.
<template>
<div class="flex h-screen flex-col">
<MessageScrollerProvider>
<MessageScroller class="flex-1">
<!-- transcript -->
</MessageScroller>
</MessageScrollerProvider>
</div>
</template>Composition
MessageScrollerProvider
└── MessageScroller
├── MessageScrollerViewport
│ └── MessageScrollerContent
│ └── MessageScrollerItem
└── MessageScrollerButton- MessageScrollerProvider - the headless root. Owns scroll state and the behavior props for opening position, auto-scroll, anchoring, scroll commands, and visibility tracking.
- MessageScroller - the styled frame. Lays out the viewport, content, and controls inside the provider.
- MessageScrollerViewport - the scrollable element. Receives native scroll events and preserves the visible row when older messages are prepended.
- MessageScrollerContent - the transcript container. Holds the rows and provides the live-region defaults for new messages.
- MessageScrollerItem - the transcript row boundary. Wrap every direct child of the content so the scroller can measure, anchor, preserve position, track visibility, and jump to it. An item can be a message, marker, typing indicator, separator, join or leave event, or "load earlier" row.
- MessageScrollerButton - the scroll control. Scrolls to the start or end of the transcript and is inert until there is content in its direction.
Core Concepts
Anchoring Turns
A turn is the part of the conversation that starts a new exchange. In a simple AI chat, that is usually the user's message and the assistant reply that follows.
An anchor is the row the viewport should treat as the start of that turn. Mark that row with scrollAnchor. When a new anchor is appended, the viewport moves it near the top and keeps a peek of the previous item above it, so the new turn does not feel detached from its context.
Scroll anchors are not tied to message role. You can turn any row into an anchor: a user message, a system marker, a handoff event, or anything else that starts a meaningful turn. MessageScroller only needs to know which row should anchor the viewport.
In the example below the user's message is anchored. When you send a new message, the viewport anchors it near the top and appends the assistant reply below it. Toggle the anchor to the assistant's message to see the difference.
<!-- This tells the scroller to anchor the user's message for the next turn. -->
<MessageScrollerItem
:message-id="message.id"
:scroll-anchor="message.role === 'user'"
>
<!-- ... -->
</MessageScrollerItem>Anchoring Turns
Choose which role settles near the top edge.
Send the first message to see the selected role anchor.
Group Chat
In a group chat, the turn boundary is more specific than "the user message". It is often the message that asks the model to respond, or a marker like "Marcus joined the chat". Typing indicators and history controls usually should not anchor.
Because anchoring is role-independent, you can anchor a marker just as easily as a message.
<MessageScrollerItem message-id="marcus-joined" scroll-anchor>
<Marker variant="separator">
<MarkerContent>Marcus joined the chat</MarkerContent>
</Marker>
</MessageScrollerItem>Group Chat
A group chat with several participants and an assistant. The Marker is marked as a turn.
Keeping Context Visible
When a new turn starts, it should still feel like part of the same continuous thread. scrollPreviousItemPeek keeps a slice of the previous item visible above the anchor, so the reader keeps their context instead of feeling like the conversation restarted on a blank page.
Adjust the peek amount in the example below to see how it affects the conversation.
<!-- Keep 64px of the previous turn visible above the newly anchored row. -->
<MessageScrollerProvider :scroll-previous-item-peek="64">
<MessageScroller>
<!-- anchored turns -->
</MessageScroller>
</MessageScrollerProvider>Keeping Context Visible
New turns keep part of the previous reply in view.
Following the Live Edge
When the reader is at the live edge, either because they stayed there or returned there, autoScroll keeps streamed replies in view as they grow. Scrolling away from the live edge releases the view, whether by wheel, touch, keyboard scroll keys, or dragging the scrollbar. An explicit message jump releases it too. New chunks can then arrive without moving the reader.
autoScroll composes with turn anchoring. When a new turn anchors near the top, the view stays put while the reply streams into the room below it. Once the reply fills the viewport, the reader is back at the live edge and follow-output takes over from the anchor.
Calling scrollToEnd, or pressing MessageScrollerButton, re-engages follow-output when autoScroll is enabled, so a reader who scrolled away can return to the live edge and keep following. The root and viewport expose data-autoscrolling while that programmatic scroll to the latest message runs, so you can conditionally apply styles during the transition.
<MessageScrollerProvider auto-scroll>
<MessageScroller>
<!-- streamed turns -->
</MessageScroller>
</MessageScrollerProvider>Streaming Messages
Auto-scroll follows the live edge of the conversation.
Press send to stream a scripted reply.
Opening Saved Threads
It can seem reasonable to reopen a saved thread at the absolute end of the transcript, but that often drops the reader into the conversation without enough context. A better default is "last-anchor": show the last meaningful turn, like the user's latest message, with the reply below it.
That gives the reader an immediate place in the thread. They can see what they asked, where the answer starts, and continue from there without reconstructing the conversation from the bottom edge.
"last-anchor" is keyed on scrollAnchor, not message role. If no anchor exists, or the last anchored turn already fits in the viewport, it falls back to "end".
Use "start" when you want to resume at the beginning of a conversation, or "end" when the absolute latest message is the right place to land.
<MessageScrollerProvider default-scroll-position="last-anchor">
<MessageScroller>
<!-- transcript -->
</MessageScroller>
</MessageScrollerProvider>Opening Position
Choose where a saved transcript opens.
Avoiding a Flash on Reload
A scroll container always opens at the top. HTML has no way to set scrollTop, so a server-rendered transcript shows the oldest messages first. After JavaScript runs, defaultScrollPosition moves the view, and you see a jump.
When defaultScrollPosition is "end" or "last-anchor", the root and viewport carry data-pending-scroll until that position is applied. The styled viewport stays invisible while the attribute is present, so you see the frame instead of the jump. "start" does not need this.
If you want "end" visible on first paint, add an inline script that runs after the viewport is in the HTML. Give the viewport an id, scroll it to the bottom, and remove data-pending-scroll.
<script setup>
useHead({
script: [
{
innerHTML: `(function () {
var viewport = document.getElementById("messages")
if (!viewport) {
return
}
viewport.scrollTop = viewport.scrollHeight
viewport.removeAttribute("data-pending-scroll")
})()`,
tagPosition: "bodyClose",
},
],
});
</script>
<template>
<MessageScrollerProvider default-scroll-position="end">
<MessageScroller>
<MessageScrollerViewport id="messages">
<MessageScrollerContent>
<!-- transcript -->
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</template>Opening Without a Flash
The viewport stays hidden until the opening position lands.
Put the script in your page, not in the scroller. It only works for "end", and only when the messages are already in the HTML. If you use a Content Security Policy, pass a nonce.
Do not use this script with "last-anchor". Skip it when messages load on the client.
Loading Earlier Messages
Loading earlier messages should not move the conversation the reader is already looking at. When older rows are prepended above the current transcript, MessageScrollerViewport preserves the visible row so the reader stays in the same place while history loads above them.
This is enabled by default through preserveScrollOnPrepend.
Use stable messageId values for message rows. That gives the scroller a specific row to preserve instead of guessing from whichever pixel happens to sit at the viewport edge.
Load History
Prepended messages keep your place.
Animating New Messages
MessageScrollerItem can be animated directly. Keep messageId and scrollAnchor on it, and use transform and opacity for the entrance.
A common chat pattern is to animate the user's message when it is sent, then let the assistant reply stream into a regular row below it. Start the user row below its final position so it feels like it rises from the live edge of the viewport.
Avoid animating height, margin, or padding for row entrances; those changes can fight the scroller's positioning work. If the reader prefers reduced motion, skip the entrance animation and keep the scroll behavior the same.
Animation
Choose how user messages are animated when they are added to the conversation.
Click the button below to send the first message.
Jumping to Messages
Search results, permalinks, outline items, and toolbar buttons often need to drive the transcript from outside the message list. Use useMessageScroller for those controls. Because the composables read from MessageScrollerProvider, they work in any component inside the provider, including controls rendered outside the MessageScroller frame.
scrollToMessage targets the messageId on MessageScrollerItem, so rows that need to be addressable should have stable ids.
scrollToMessage can queue a target before items exist, which covers client-resolved permalinks while the transcript mounts. After rows have mounted, a missing id returns false instead of starting a guessed retry loop. A true result means the scroll ran or was queued, not that the row is already in view.
<script setup>
import { useMessageScroller } from "@/components/ui/message-scroller";
const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller();
</script>Commands
Drive the transcript from outside.
Tracking the Reader's Position
Use useMessageScrollerVisibility to track the reader's position in the conversation. A common example is a table of contents or a jump menu that highlights the current anchored turn.
currentAnchorId answers "where am I" by reporting the current anchored turn, and it stays set after that anchor scrolls above the viewport. visibleMessageIds answers "what is on screen", in document order.
Visibility is pay-for-what-you-use. Tracking only runs while something subscribes to useMessageScrollerVisibility, and rows need a messageId to participate.
<script setup>
import { useMessageScrollerVisibility } from "@/components/ui/message-scroller";
const visibility = useMessageScrollerVisibility();
// visibility.value.currentAnchorId, visibility.value.visibleMessageIds
</script>Transcript Outline
Track the current anchored turn.
Reading Scroll State
Use useMessageScrollerScrollable when you need scroll state in JavaScript, such as a status indicator or a custom "jump to latest" control. It reports which edges the viewport can still scroll toward; "at the start or end" is the negation (!start / !end), and "scrollable at all" is start || end. For styling the scroller itself, prefer the data-scrollable attribute.
<script setup>
import { useMessageScrollerScrollable } from "@/components/ui/message-scroller";
const scrollable = useMessageScrollerScrollable();
// scrollable.value.start, scrollable.value.end
</script>Scroll Status
Where the reader can scroll to based on current scroll position.
Performance
MessageScroller is benchmarked against large transcripts with markdown and composed message rows.
The goal is to keep the scroll hot path outside of Vue's reactivity: no component updates for transcript rows, no forced layout on every scroll, and as little off-screen paint work as the browser can avoid.
Scroll position, anchoring, and follow-output are tracked imperatively and mirrored onto the root and viewport through data-* attributes, so scrolling and streaming do not re-render transcript rows.
The styled MessageScrollerItem also ships with content-visibility: auto and contain-intrinsic-size. Rows stay in the DOM for selection, copy, find-in-page, SSR, and assistive tech, but the browser can skip rendering work for rows far outside the viewport.
Visibility tracking is pay-for-what-you-use. A jump menu or active turn indicator costs nothing until something subscribes to useMessageScrollerVisibility.
This is comfortable for the expected range of a chat transcript: hundreds to low thousands of turns, including messages with markdown and composed components.
Virtualization
Virtualization is intentionally left outside the primitive. MessageScroller renders real DOM rows and stays fast well into the thousands of turns, so most transcripts never need it.
When a transcript is large enough to need virtualization, use MessageScrollerViewport as the scroll element and let the virtualizer own the rows.
<script setup>
import { computed, ref } from "vue";
import { useVirtualizer } from "@tanstack/vue-virtual";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller";
const props = defineProps({ messages: { type: Array, required: true } });
// MessageScrollerViewport has a single root element, so $el is the scroller.
const viewportRef = ref(null);
// Wrapped in computed(): useVirtualizer reads the options object once unless it
// is a ref, so a plain object would freeze count at the first render's length.
const virtualizer = useVirtualizer(
computed(() => ({
count: props.messages.length,
getScrollElement: () => viewportRef.value?.$el ?? null,
estimateSize: () => 86,
getItemKey: (index) => props.messages[index]?.id ?? index,
overscan: 8,
}))
);
</script>
<template>
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport ref="viewportRef">
<MessageScrollerContent class="block min-h-full">
<div
class="relative w-full"
:style="{ height: `${virtualizer.getTotalSize()}px` }"
>
<div
v-for="virtualItem in virtualizer.getVirtualItems()"
:key="virtualItem.key"
:ref="virtualizer.measureElement"
:data-index="virtualItem.index"
class="absolute start-0 top-0 w-full"
:style="{ transform: `translateY(${virtualItem.start}px)` }"
>
{{ messages[virtualItem.index]?.content }}
</div>
</div>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</template>Streaming markdown
Not part of the upstream page. A local extra that shows the same hero chat with assistant replies rendered as streamed markdown, to check that anchoring and follow-output survive content that reflows while it arrives.
The markdown renderer is a dependency of this docs site, not of MessageScroller.
New Chat
How can I help you today?
Morning!
What are we working on today? Press send to start a new conversation
Demo is read only. Press send to send messages.
Data attributes
The engine mirrors its state onto the DOM, so you can style by scroll state without reading anything in JavaScript.
- data-slot - on every part: "message-scroller", "message-scroller-viewport", "message-scroller-content", "message-scroller-item", "message-scroller-button".
- data-scrollable - on the root and the viewport: "start", "end", "start end", or absent when the transcript fits. Query one edge with [data-scrollable~="end"].
- data-autoscrolling - on the root and the viewport, present while the viewport is programmatically scrolling to the latest message.
- data-pending-scroll - on the root and the viewport, present until defaultScrollPosition ("end" or "last-anchor") is applied, or skipped for an empty transcript. The styled viewport is invisible while it is set.
- role="log" and aria-relevant="additions" - on the content, alongside data-slot="message-scroller-content".
- data-message-id and data-scroll-anchor - on each item, mirroring messageId and scrollAnchor.
- data-direction and data-active - on the button, mirroring direction and whether it can currently scroll.
- data-message-scroller-spacer - a hidden, aria-hidden spacer div inside the content. It makes room below the last anchored turn and is never rendered to the reader.
Anatomy
Import all parts and piece them together.
<script setup>
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller";
</script>
<template>
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
<MessageScrollerItem />
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</template>API Reference
MessageScrollerProvider
MessageScroller
MessageScrollerViewport
MessageScrollerContent
MessageScrollerItem
MessageScrollerButton
useMessageScroller()
scrollToMessage options
useMessageScrollerScrollable()
useMessageScrollerVisibility()
Accessibility
Keyboard shortcuts and ARIA behavior.
- MessageScrollerViewport is a labelled, keyboard-focusable scroll region by default. It uses role="region", aria-label="Messages" and tabindex="0", so keyboard users can focus the transcript and scroll it directly.
- MessageScrollerContent marks the transcript as a live region with role="log" and aria-relevant="additions". New rows can be announced, but streamed text mutations do not have to be announced token by token.
- Pass aria-busy on MessageScrollerContent while a turn streams if announcements should wait for the completed message row.
- MessageScrollerButton renders a real button. When there is nothing to scroll toward it becomes inert, uses tabindex="-1" and exposes data-active="false", so inactive scroll controls do not create extra focus stops.
- Any scroll-intent key releases follow-output, so a keyboard reader is never pulled back to the live edge while reading.