Components
Message Scroller
A scroll container for chat transcripts that anchors turns, follows streamed replies, restores prepended history, and jumps to messages.
Source code
Click to see the source code for this component on GitHub. Feel free to copy it and adjust it for your own use.
Installation
Add CSS
Add this to your main CSS file or import it in your project:
@keyframes ms-anim-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes ms-anim-slide-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes ms-anim-slide-side {
from {
opacity: 0;
transform: translateX(18px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes ms-anim-pop {
from {
opacity: 0;
transform: translateY(6px) scale(0.94);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes ms-anim-spring-bounce {
0% {
opacity: 0;
transform: translateY(12px) scale(0.96);
}
60% {
opacity: 1;
transform: translateY(-2px) scale(1.01);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes ms-anim-blur-fade {
from {
opacity: 0;
filter: blur(4px);
transform: translateY(6px);
}
to {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
@keyframes ms-anim-scale-fade {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
.ms-anim-fade {
animation: ms-anim-fade 0.26s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.ms-anim-slide-up {
animation: ms-anim-slide-up 0.26s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.ms-anim-slide-side {
animation: ms-anim-slide-side 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.ms-anim-pop {
animation: ms-anim-pop 0.28s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.ms-anim-spring-bounce {
animation: ms-anim-spring-bounce 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.ms-anim-blur-fade {
animation: ms-anim-blur-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.ms-anim-scale-fade {
animation: ms-anim-scale-fade 0.24s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@media (prefers-reduced-motion: reduce) {
[class*="ms-anim-"] {
animation: none;
}
}
MessageScroller
A great streaming chat scroller has to juggle a lot at once: pin to the live edge while a reply streams, but never fight a reader who scrolls up; anchor each new turn near the top with a peek of the previous exchange; preserve position when older history loads above; and expose commands to jump anywhere in the thread. MessageScroller owns those hard parts so your message list doesn't have to.
It does not own your messages, AI state, transport, or model — it is a headless scroll container you compose around your own rows.
Usage
New Chat
The provider must have a constrained height (or a height-bounded parent) so the viewport can scroll.
Anatomy
Message Scroller Anatomy.vue
<template>
<UiMessageScrollerProvider auto-scroll default-scroll-position="last-anchor">
<UiMessageScroller>
<UiMessageScrollerViewport>
<UiMessageScrollerContent>
<UiMessageScrollerItem
v-for="message in messages"
:key="message.id"
:message-id="message.id"
:scroll-anchor="message.role === 'user'"
>
<!-- Message / Bubble / Marker goes here -->
</UiMessageScrollerItem>
</UiMessageScrollerContent>
</UiMessageScrollerViewport>
<UiMessageScrollerButton direction="end" />
</UiMessageScroller>
</UiMessageScrollerProvider>
</template>
Examples
Anchoring Turns
A turn is the part of the conversation that starts a new exchange — 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.
<template>
<UiMessageScrollerItem :message-id="message.id" :scroll-anchor="message.role === 'user'">
<!-- ... -->
</UiMessageScrollerItem>
</template>
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.
Anchoring Turns
Group Chat
In a group chat, the turn boundary 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.
Group Chat
This will create a marker and make it the anchor
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.
Keeping Context Visible
Following the Live Edge
When the reader is at the live edge, autoScroll keeps streamed replies in view as they grow. Scrolling away from the live edge — by wheel, touch, keyboard, or dragging the scrollbar — releases the view, so new chunks 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.
Streaming Messages
Opening Saved Threads
Reopening a saved thread at the absolute end often drops the reader in 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.
Opening Position
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, UiMessageScrollerViewport preserves the visible row so the reader stays in the same place while history loads above them. This is enabled by default through preserveScrollOnPrepend.
Load History
Restore earlier messages while keeping your place.
Animating New Messages
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. Keep messageId and scrollAnchor on the animated item and use transform and opacity for the entrance — avoid animating height, margin, or padding, which can fight the scroller's positioning.
Animation
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 — the composable reads from UiMessageScrollerProvider, so it works in any component inside the provider.
<script setup lang="ts">
const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller();
</script>
Commands
Tracking the Reader's Position
Use useMessageScrollerVisibility to track the reader's position — a table-of-contents or jump menu that highlights the current anchored turn. currentAnchorId answers "where am I" and stays set after that anchor scrolls above the viewport; visibleMessageIds answers "what is on screen", in document order.
Transcript Outline
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.
Scroll Status
API Reference
All Message Scroller parts render a <div> by default, except MessageScrollerButton, which renders a Button.
MessageScrollerProvider
Owns the scroll state and behavior. Descendants read from it with useMessageScroller, useMessageScrollerScrollable, and useMessageScrollerVisibility.
| Prop | Type | Default | Description |
|---|---|---|---|
autoScroll | boolean | false | Follow the live edge while the reader is pinned to the bottom. |
defaultScrollPosition | "start" | "end" | "last-anchor" | "end" | Opening position for the transcript. |
scrollEdgeThreshold | number | 8 | Distance in px from an edge before it is considered scrollable. |
scrollPreviousItemPeek | number | 64 | Amount in px of the previous turn kept visible when anchoring. |
scrollMargin | number | 0 | Extra offset in px applied when scrolling to an element. |
MessageScroller
The scroll region's outer wrapper. Needs a constrained height (or a height-bounded parent) so its viewport can scroll.
| Prop | Type | Default | Description |
|---|---|---|---|
class | HTMLAttributes["class"] | - | Additional classes to apply to the root element. |
MessageScrollerViewport
The scrollable region. Renders as a role="region", aria-label="Messages", focusable (tabindex="0") native scroll container.
| Prop | Type | Default | Description |
|---|---|---|---|
preserveScrollOnPrepend | boolean | true | Keep the current view when messages are added above. |
class | HTMLAttributes["class"] | - | Additional classes to apply to the viewport. |
MessageScrollerContent
Renders as role="log" with aria-relevant="additions" so assistive tech announces new rows.
| Prop | Type | Default | Description |
|---|---|---|---|
class | HTMLAttributes["class"] | - | Additional classes to apply to the content element. |
spacerClass | HTMLAttributes["class"] | - | Additional classes to apply to the trailing spacer. |
MessageScrollerItem
| Prop | Type | Default | Description |
|---|---|---|---|
messageId | string | - | Stable id used for anchoring, visibility, and jumps. |
scrollAnchor | boolean | false | Marks this row as the start of a turn. |
class | HTMLAttributes["class"] | - | Additional classes to apply to the item. |
MessageScrollerButton
Exposes data-active for styling and becomes inert with tabindex="-1" when there is nothing to scroll toward.
| Prop | Type | Default | Description |
|---|---|---|---|
direction | "start" | "end" | "end" | Direction the button scrolls toward. |
behavior | ScrollBehavior | "smooth" | Scroll behavior for the jump. |
variant | ButtonVariants["variant"] | "secondary" | The button variant. |
size | ButtonVariants["size"] | "icon-sm" | The button size. |
class | HTMLAttributes["class"] | - | Additional classes to apply. |
Composables
useMessageScroller()
const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller();
scrollToMessage(id, options?)— scroll to the item with the matchingmessageId. Returnstrueif handled (queued if the item is not mounted yet),falseif the id is missing after rows have mounted.scrollToEnd(options?)/scrollToStart(options?)— scroll to the live edge or the top.
useMessageScrollerVisibility()
const visibility = useMessageScrollerVisibility();
// visibility.value.currentAnchorId, visibility.value.visibleMessageIds
Tracking only runs while something subscribes, and rows need a messageId to participate.
useMessageScrollerScrollable()
const scrollable = useMessageScrollerScrollable();
// scrollable.value.start, scrollable.value.end
Reports which edges the viewport can still scroll toward. For styling the scroller itself, prefer the data-scrollable attribute.