Toggle Sidebar B
AppearanceLight & dark mode D

Message Scroller

The scroll container for a conversation. It handles the parts that are easy to get wrong: pinning to the bottom while a reply streams, anchoring each new turn near the top, preserving position when history is prepended, jump-to-message, scroll controls, and visibility tracking.

Streaming chat

A read-only chat. Press send to stream a scripted reply: the viewport pins to the bottom while you're caught up, anchors each new turn near the top, and surfaces the scroll button when you scroll away. Assistant replies render markdown via vue-stream-markdown.

Replay preview

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.

Basic autoScroll

The minimal setup: Provider + Viewport + Content + Items, with autoScroll on. Append a message to watch it follow.

Replay preview
Wrap a list in MessageScroller and turn on autoScroll.
The viewport pins to the bottom as new items arrive.
Press append to watch it follow along.

Controls & tracking

jump-to-message, top/bottom controls, prepend-preserve (Load older), and live visibility tracking via the composables.

Replay preview
visible 0
anchor -
Message 1 · anchor
Message 2
Message 3
Message 4
Message 5
Message 6 · anchor
Message 7
Message 8
Message 9
Message 10
Message 11 · anchor
Message 12
Message 13
Message 14
Message 15
Message 16 · anchor
Message 17
Message 18
Message 19
Message 20

Default

Provider + Viewport + Content + Items with a scroll-to-latest button. Opens at the newest message; scroll up and the button fades in.

Replay preview
Hey! Welcome to the support channel. What can I help you with?
Hi — I can't get my scroll container to stay pinned to the bottom.
That's a classic one. Are you appending messages to a plain overflow div?
Yeah, just a div with overflow-y-auto and a v-for.
Right, so it never follows new content unless you manually set scrollTop.
Exactly, and doing it by hand fights the user when they scroll up.
MessageScroller handles that for you. It only pins while the reader is already at the bottom.
So if I scroll up to re-read something it leaves me alone?
Correct. The moment you scroll up, auto-follow backs off and your position is kept.
And when new content arrives while I'm scrolled up?
A scroll-to-latest button fades in. One tap jumps you back down.
That's exactly the behaviour I wanted. This opens at the bottom too?
Yep — defaultScrollPosition is "end", so the newest message is in view on first render.
Perfect. Scroll up in this preview and the button should appear.
It will. Give it a try 👇

Group chat

A multi-sender thread. Your own turns align to the end; everyone else gets an avatar and a name header on the start.

Replay preview
MA
Maya
Morning all — did the staging deploy go out last night?
DE
Dev
It did, around 11pm. Smoke tests are green.
Nice. I'll start the QA pass after standup.
PR
Priya
I already poked at the chat view — scroll anchoring feels great now.
MA
Maya
That was the big fix. New turns settle near the top instead of snapping.
Yeah, no more disorienting jump when a reply comes in.
DE
Dev
And history loading keeps your place when you scroll back up.
PR
Priya
Confirmed. Loaded three pages of older messages, position never moved.
Ship it. I'll write the release notes.
MA
Maya
Thanks team 🎉
DE
Dev
One thing — the avatars align opposite for your own messages, right?
Right, mine sit on the end, everyone else on the start.

Streaming reply

With autoScroll on, the viewport pins to the bottom while a reply streams in token by token. Press Replay to watch it again.

Replay preview

Explain how the auto-scroll keeps up while a reply streams in.

Turn anchoring

Mark a turn with scrollAnchor and it settles near the top with a peek of the previous exchange above it, instead of snapping to the bottom.

Replay preview
Ask me anything about the scroller and I'll answer below.
What does scrollAnchor actually do?
It marks a turn as the one to settle near the top of the viewport when new content arrives, keeping a small peek of the previous turn visible above it.
So the new answer starts in view instead of below the fold?
Exactly. The reply begins at a comfortable reading position — no jump to the bottom, no lost context.

Opening position

defaultScrollPosition decides where the thread lands on first render: the top, the newest message, or the last anchored turn.

Replay preview
Opens at:
Message 1
Message 2 · anchor
Message 3
Message 4
Message 5
Message 6
Message 7
Message 8
Message 9
Message 10
Message 11
Message 12 · anchor
Message 13
Message 14
Message 15
Message 16

Scrollable edges

A control at each edge. Each button reflects the scrollable state: it appears only when there's more content past that edge.

Replay preview
Line 1: the viewport tracks how far you are from each edge.
Line 2: got it — scroll me and watch the edge buttons toggle.
Line 3: the viewport tracks how far you are from each edge.
Line 4: got it — scroll me and watch the edge buttons toggle.
Line 5: the viewport tracks how far you are from each edge.
Line 6: got it — scroll me and watch the edge buttons toggle.
Line 7: the viewport tracks how far you are from each edge.
Line 8: got it — scroll me and watch the edge buttons toggle.
Line 9: the viewport tracks how far you are from each edge.
Line 10: got it — scroll me and watch the edge buttons toggle.
Line 11: the viewport tracks how far you are from each edge.
Line 12: got it — scroll me and watch the edge buttons toggle.
Line 13: the viewport tracks how far you are from each edge.
Line 14: got it — scroll me and watch the edge buttons toggle.
Line 15: the viewport tracks how far you are from each edge.
Line 16: got it — scroll me and watch the edge buttons toggle.
Line 17: the viewport tracks how far you are from each edge.
Line 18: got it — scroll me and watch the edge buttons toggle.
Line 19: the viewport tracks how far you are from each edge.
Line 20: got it — scroll me and watch the edge buttons toggle.
Line 21: the viewport tracks how far you are from each edge.
Line 22: got it — scroll me and watch the edge buttons toggle.
Line 23: the viewport tracks how far you are from each edge.
Line 24: got it — scroll me and watch the edge buttons toggle.
Line 25: the viewport tracks how far you are from each edge.
Line 26: got it — scroll me and watch the edge buttons toggle.
Line 27: the viewport tracks how far you are from each edge.
Line 28: got it — scroll me and watch the edge buttons toggle.
Line 29: the viewport tracks how far you are from each edge.
Line 30: got it — scroll me and watch the edge buttons toggle.

Preserve context on prepend

preserveScrollOnPrepend keeps the reader's position when older history is added above. Toggle it off to feel the content shift under you.

Replay preview
Recent message 1
Recent message 2
Recent message 3
Recent message 4
Recent message 5
Recent message 6
Recent message 7
Recent message 8
Recent message 9
Recent message 10
Recent message 11
Recent message 12
Recent message 13
Recent message 14

Load history

Infinite-history pattern: a Load older button prepends a page after a short fetch while your scroll position stays put.

Replay preview
Message 1
Message 2
Message 3
Message 4
Message 5
Message 6
Message 7
Message 8
Message 9
Message 10
Message 11
Message 12

Animated items

New items fade and slide in via a CSS keyframe on a wrapper inside each item, so the entrance never disturbs the engine's measurement.

Replay preview
Press send to append an animated message.

Imperative commands

useMessageScroller() exposes scrollToStart, scrollToEnd, and scrollToMessage(id) for programmatic jumps from your own controls.

Replay preview
Message 1
Message 2
Message 3
Message 4
Message 5
Message 6
Message 7
Message 8
Message 9
Message 10
Message 11
Message 12
Message 13
Message 14
Message 15
Message 16
Message 17
Message 18
Message 19
Message 20
Message 21
Message 22
Message 23
Message 24

Visibility tracking

useMessageScrollerVisibility() reports which messages are on screen and the current anchor, tracked live via IntersectionObserver.

Replay preview
visible 0
anchor —
Message 1 · anchor
Message 2
Message 3
Message 4
Message 5 · anchor
Message 6
Message 7
Message 8
Message 9 · anchor
Message 10
Message 11
Message 12
Message 13 · anchor
Message 14
Message 15
Message 16
Message 17 · anchor
Message 18
Message 19
Message 20
Message 21 · anchor
Message 22
Message 23
Message 24

Anatomy

Import all parts and piece them together.

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

PropTypeDefaultDescription
autoScrollbooleanfalsePin to the bottom as content grows while the reader is already there.
defaultScrollPosition"start" | "end" | "last-anchor""end"Where to land on first render.
scrollEdgeThresholdnumber8Pixels from an edge still considered 'at the edge'.
scrollPreviousItemPeeknumber64How much of the previous turn stays visible above an anchored turn.
scrollMarginnumber0Extra offset applied when scrolling an element into view.

MessageScrollerViewport

PropTypeDefaultDescription
preserveScrollOnPrependbooleantrueKeep the reader's position when older items are prepended.
ariaLabelstring"Messages"Accessible label for the scroll region.

MessageScrollerItem

PropTypeDefaultDescription
messageIdstringStable id used for jump-to-message and visibility.
scrollAnchorbooleanfalseMark this turn as the one to settle near the top.

MessageScrollerButton

PropTypeDefaultDescription
direction"start" | "end""end"Scroll to the start or end. Hidden when already there.
behavior"auto" | "smooth""smooth"Scroll behavior on click.

useMessageScroller()

PropTypeDefaultDescription
scrollToEnd(options?)(opts?) => booleanScroll to the newest message and re-engage auto-scroll.
scrollToStart(options?)(opts?) => booleanScroll to the top.
scrollToMessage(id, options?)(id, opts?) => booleanJump to a message by id.

useMessageScrollerScrollable() / useMessageScrollerVisibility()

PropTypeDefaultDescription
scrollable{ start: boolean, end: boolean }Whether more content exists past each edge.
visibility{ currentAnchorId: string | null, visibleMessageIds: string[] }Live visibility / current anchor, tracked via IntersectionObserver.