10.3k

Message Scroller

PreviousNext

A scroll container for chat transcripts that anchors turns, follows streamed replies, restores prepended history, and jumps to messages.

New Chat

How can I help you today?

Morning, shadcn!

What are we working on today? Press send to start a new conversation

I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.
Demo is read only. Press send to send messages.
<script setup lang="ts">
import {
  ArrowUpIcon,
  GlobeIcon,
  ImageIcon,
  MessageCircleDashedIcon,
  PaperclipIcon,
  PlusIcon,
  RotateCwIcon,
  TelescopeIcon,
} from '@lucide/vue'
import { computed } from 'vue'
import { createDemoChat, useDemoChat } from '@/lib/message-scroller-demo'
import { Button } from '@/components/ui/button'
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/components/ui/empty'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
} from '@/components/ui/input-group'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from '@/components/ui/message-scroller'
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/ui/tooltip'

const chat = createDemoChat()
  .user('I\'m building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.')
  .assistant('That\'s the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll` — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\n\nThe important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved.')
  .user('Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.')
  .assistant('MessageScrollerItem fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.\n\nIt also leaves a small peek of the previous exchange visible above the anchor, so context isn\'t lost. The reply starts in view without that disorienting jump you get from a plain overflow container.')
  .user('And if they\'ve scrolled up to re-read an older answer? I don\'t want to yank them back down.')
  .assistant('You won\'t. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out — their place in the thread stays put even as new tokens keep arriving below.\n\nWhen there is content they haven\'t seen yet, `MessageScrollerButton` appears at the bottom of the viewport. One tap jumps them back to the newest message and re-engages auto-scroll.')
  .user('Last one — does this work with assistive tech?')
  .assistant('`MessageScrollerContent` sets `role="log"` and `aria-relevant="additions"` by default, so screen readers announce new messages as they stream in.\n\nThe scroll button is a real `<button>` with an sr-only label, and it\'s removed from the tab order when you\'re already at the bottom — no ghost focus stops.')

const { messages, status, nextMessage, send, reset } = useDemoChat(chat, { chunkDelayMs: 20 })

const isBusy = computed(() => status.value === 'streaming')

function onSubmit() {
  if (!nextMessage.value || isBusy.value)
    return
  send()
}
</script>

<template>
  <MessageScrollerProvider>
    <div class="relative flex flex-col gap-4">
      <Card class="mx-auto h-140 w-full max-w-sm gap-0">
        <CardHeader class="gap-1 border-b">
          <CardTitle>New Chat</CardTitle>
          <CardDescription>How can I help you today?</CardDescription>
          <CardAction>
            <Tooltip>
              <TooltipTrigger as-child>
                <Button
                  variant="outline"
                  size="icon"
                  aria-label="Reset conversation"
                  :disabled="isBusy"
                  @click="reset"
                >
                  <RotateCwIcon />
                </Button>
              </TooltipTrigger>
              <TooltipContent>
                <p>Reset</p>
              </TooltipContent>
            </Tooltip>
          </CardAction>
        </CardHeader>
        <CardContent class="flex-1 overflow-hidden p-0">
          <Empty v-if="messages.length === 0" class="h-full">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <MessageCircleDashedIcon />
              </EmptyMedia>
              <EmptyTitle>Morning, shadcn!</EmptyTitle>
              <EmptyDescription>
                What are we working on today? Press send to start a new conversation
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
          <MessageScroller v-else>
            <MessageScrollerViewport>
              <MessageScrollerContent :aria-busy="isBusy" class="p-6">
                <MessageAnimated
                  v-for="message in messages"
                  :key="message.id"
                  :message="message"
                  :scroll-anchor="message.role === 'user'"
                />
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </CardContent>
        <CardFooter class="flex-col gap-2">
          <form class="w-full" @submit.prevent="onSubmit">
            <InputGroup>
              <div class="h-14 w-full px-3 py-2.5">
                <span
                  class="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
                  :data-status="status"
                >
                  <template v-if="nextMessage">{{ nextMessage.text }}</template>
                  <span v-else class="text-muted-foreground">
                    No messages queued. Reset the conversation.
                  </span>
                </span>
              </div>
              <InputGroupAddon align="block-end" class="pt-1">
                <DropdownMenu>
                  <DropdownMenuTrigger as-child>
                    <InputGroupButton
                      aria-label="Add files"
                      type="button"
                      size="icon-sm"
                      variant="outline"
                    >
                      <PlusIcon />
                    </InputGroupButton>
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="start" side="top" class="w-44">
                    <DropdownMenuItem>
                      <PaperclipIcon />
                      Add Photos &amp; Files
                    </DropdownMenuItem>
                    <DropdownMenuSeparator />
                    <DropdownMenuItem>
                      <ImageIcon />
                      Create Image
                    </DropdownMenuItem>
                    <DropdownMenuItem>
                      <TelescopeIcon />
                      Deep Research
                    </DropdownMenuItem>
                    <DropdownMenuItem>
                      <GlobeIcon />
                      Web Search
                    </DropdownMenuItem>
                  </DropdownMenuContent>
                </DropdownMenu>
                <InputGroupButton
                  type="submit"
                  variant="default"
                  size="icon-sm"
                  class="ml-auto"
                  :disabled="!nextMessage || isBusy"
                >
                  <ArrowUpIcon />
                  <span class="sr-only">Send</span>
                </InputGroupButton>
              </InputGroupAddon>
            </InputGroup>
          </form>
        </CardFooter>
      </Card>
      <div class="px-0.5 text-center text-xs text-muted-foreground">
        Demo is read only. Press send to send messages.
      </div>
    </div>
  </MessageScrollerProvider>
</template>

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.

Installation

pnpm dlx shadcn-vue@latest add message-scroller

Usage

<script setup lang="ts">
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 / 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.

Composition

MessageScrollerProvider
└── MessageScroller
    ├── MessageScrollerViewport
    │   └── MessageScrollerContent
    │       └── MessageScrollerItem
    └── MessageScrollerButton

Core Concepts

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.

<MessageScrollerItem
  :message-id="message.id"
  :scroll-anchor="message.role === 'user'"
>
  <!-- ... -->
</MessageScrollerItem>

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

Choose which role settles near the top edge.

No anchored messages yet

Send the first message to see the selected role anchor.

Toggle the anchor role, then send messages to compare where turns settle.
<script setup lang="ts">
import type { DemoMessage, DemoRole } from '@/lib/message-scroller-demo'
import {
  ArrowUpIcon,
  MessageCircleDashedIcon,
  RotateCwIcon,
} from '@lucide/vue'
import { computed, ref } from 'vue'
import { Button } from '@/components/ui/button'
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/components/ui/empty'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from '@/components/ui/message-scroller'
import {
  ToggleGroup,
  ToggleGroupItem,
} from '@/components/ui/toggle-group'

const scriptedMessages: DemoMessage[] = [
  {
    id: 'anchor-1-user',
    role: 'user',
    text: 'Can you show me how anchoring behaves when a new prompt starts the turn?',
  },
  {
    id: 'anchor-1-assistant',
    role: 'assistant',
    text: 'Append the user prompt first, then append the assistant response. With User selected, the prompt settles near the top and the assistant response fills in below it.',
  },
  {
    id: 'anchor-2-user',
    role: 'user',
    text: 'What changes when assistant messages are the anchor?',
  },
  {
    id: 'anchor-2-assistant',
    role: 'assistant',
    text: 'Now each assistant response is the item `MessageScroller` keeps in view. This is useful when the reply is the moment you want readers to land on after each turn.',
  },
  {
    id: 'anchor-3-user',
    role: 'user',
    text: 'Can I switch roles and keep adding turns?',
  },
  {
    id: 'anchor-3-assistant',
    role: 'assistant',
    text: 'Yes. The next appended message with the selected role becomes the anchor, so you can compare user and assistant anchoring without resetting the demo.',
  },
]

const anchorRole = ref<DemoRole>('user')
const messages = ref<DemoMessage[]>([])
const messageIndex = ref(0)
const nextMessage = computed<DemoMessage | null>(() => scriptedMessages[messageIndex.value] ?? null)

function reset() {
  messages.value = []
  messageIndex.value = 0
}

function onValueChange(value: unknown) {
  const nextValue = Array.isArray(value) ? value[0] : value

  if (nextValue === 'user' || nextValue === 'assistant') {
    anchorRole.value = nextValue
    reset()
  }
}

function send() {
  if (!nextMessage.value)
    return

  messages.value = [...messages.value, nextMessage.value]
  messageIndex.value += 1
}
</script>

<template>
  <div class="relative flex flex-col gap-4">
    <Card class="mx-auto h-140 w-full max-w-sm gap-0">
      <CardHeader class="border-b">
        <CardTitle>Anchoring Turns</CardTitle>
        <CardDescription>
          Choose which role settles near the top edge.
        </CardDescription>
        <CardAction>
          <Button
            type="button"
            variant="outline"
            size="icon"
            aria-label="Reset anchored turns"
            :disabled="messages.length === 0"
            @click="reset"
          >
            <RotateCwIcon />
          </Button>
        </CardAction>
      </CardHeader>
      <CardContent class="min-h-0 flex-1 overflow-hidden p-0">
        <Empty v-if="messages.length === 0" class="h-full">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <MessageCircleDashedIcon />
            </EmptyMedia>
            <EmptyTitle>No anchored messages yet</EmptyTitle>
            <EmptyDescription>
              Send the first message to see the selected role anchor.
            </EmptyDescription>
          </EmptyHeader>
        </Empty>
        <MessageScrollerProvider v-else>
          <MessageScroller>
            <MessageScrollerViewport>
              <MessageScrollerContent class="p-6">
                <MessageAnimated
                  v-for="message in messages"
                  :key="message.id"
                  :message="message"
                  :scroll-anchor="message.role === anchorRole"
                  user-variant="muted"
                  assistant-variant="ghost"
                />
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </MessageScrollerProvider>
      </CardContent>
      <CardFooter>
        <ToggleGroup
          type="multiple"
          aria-label="Select scroll anchor role"
          :model-value="[anchorRole]"
          @update:model-value="onValueChange"
        >
          <ToggleGroupItem value="user" aria-label="Anchor user messages">
            User
          </ToggleGroupItem>
          <ToggleGroupItem value="assistant" aria-label="Anchor assistant messages">
            Assistant
          </ToggleGroupItem>
        </ToggleGroup>
        <Button
          type="button"
          size="icon"
          class="ml-auto"
          :disabled="!nextMessage"
          @click="send"
        >
          <ArrowUpIcon />
          <span class="sr-only">Send Message</span>
        </Button>
      </CardFooter>
    </Card>
    <div class="mx-auto max-w-xs px-0.5 text-center text-xs text-muted-foreground">
      Toggle the anchor role, then send messages to compare where turns
      settle.
    </div>
  </div>
</template>

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

A group chat with several participants and an assistant. The Marker is marked as a turn.

@mary, the astrophage line keeps matching Venus energy output. Can you check my math?
Mary (Agent)
Yes. Confirmed. The curve points to a microorganism harvesting stellar energy and breeding near carbon dioxide. If @rocky agrees, this is the clue we need.
ping @rocky

This will create a marker and make it the anchor

When a user joins, a marker is created. scrollAnchor on the marker marks it as the next turn
<script setup lang="ts">
import { RotateCwIcon } from '@lucide/vue'
import { computed, ref } from 'vue'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Marker, MarkerContent } from '@/components/ui/marker'
import {
  Message,
  MessageContent,
  MessageHeader,
} from '@/components/ui/message'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from '@/components/ui/message-scroller'
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/ui/tooltip'

type GroupChatItem
  = | {
    id: string
    type: 'event'
    text: string
    scrollAnchor?: boolean
  }
  | {
    id: string
    type: 'message'
    sender: string
    role: 'assistant' | 'participant'
    text: string
    scrollAnchor?: boolean
  }

const currentUser = 'Grace'

const initialItems: GroupChatItem[] = [
  {
    id: 'group-1',
    type: 'message',
    sender: 'Grace',
    role: 'participant',
    text: '@mary, the astrophage line keeps matching Venus energy output. Can you check my math?',
  },
  {
    id: 'group-2',
    type: 'message',
    sender: 'Mary (Agent)',
    role: 'assistant',
    text: 'Yes. Confirmed. The curve points to a microorganism harvesting stellar energy and breeding near carbon dioxide. If @rocky agrees, this is the clue we need.',
  },
  {
    id: 'group-3',
    type: 'message',
    sender: 'Grace',
    role: 'participant',
    text: 'ping @rocky',
    scrollAnchor: true,
  },
]

const rockyMarker: GroupChatItem = {
  id: 'group-4',
  type: 'event',
  text: 'Rocky has joined the chat',
  scrollAnchor: true,
}

const rockyMessage: GroupChatItem = {
  id: 'group-5',
  type: 'message',
  sender: 'Rocky',
  role: 'participant',
  text: 'Amaze. Astrophage eats light, makes heat, goes to carbon dioxide. Rocky has fuel model. Grace is smart.',
}

const demoKey = ref(0)
const rockyTurn = ref<'idle' | 'marker' | 'message'>('idle')

const items = computed(() => {
  if (rockyTurn.value === 'message')
    return [...initialItems, rockyMarker, rockyMessage]
  if (rockyTurn.value === 'marker')
    return [...initialItems, rockyMarker]
  return initialItems
})
const buttonLabel = computed(() =>
  rockyTurn.value === 'idle' ? 'Add Rocky' : 'Send Message as Rocky',
)
const isComplete = computed(() => rockyTurn.value === 'message')

function advance() {
  rockyTurn.value = rockyTurn.value === 'idle' ? 'marker' : 'message'
}

function reset() {
  rockyTurn.value = 'idle'
  demoKey.value += 1
}

function messageVariant(item: Extract<GroupChatItem, { type: 'message' }>) {
  if (item.sender === currentUser)
    return 'muted'
  return item.role === 'assistant' ? 'ghost' : 'tinted'
}
</script>

<template>
  <MessageScrollerProvider>
    <div class="relative flex flex-col gap-4">
      <Card class="mx-auto h-140 w-full max-w-sm gap-0">
        <CardHeader class="gap-1 border-b">
          <CardTitle>Group Chat</CardTitle>
          <CardDescription>
            A group chat with several participants and an assistant. The
            Marker is marked as a turn.
          </CardDescription>
          <CardAction>
            <Tooltip>
              <TooltipTrigger as-child>
                <Button
                  type="button"
                  variant="outline"
                  size="icon"
                  aria-label="Reset conversation"
                  :disabled="rockyTurn === 'idle'"
                  @click="reset"
                >
                  <RotateCwIcon />
                </Button>
              </TooltipTrigger>
              <TooltipContent>
                <p>Reset</p>
              </TooltipContent>
            </Tooltip>
          </CardAction>
        </CardHeader>
        <CardContent class="min-h-0 flex-1 p-0">
          <MessageScrollerProvider>
            <MessageScroller :key="demoKey">
              <MessageScrollerViewport>
                <MessageScrollerContent class="p-6">
                  <template v-for="item in items" :key="item.id">
                    <MessageScrollerItem
                      v-if="item.type === 'message'"
                      :message-id="item.id"
                      :scroll-anchor="item.scrollAnchor"
                    >
                      <Message :align="item.sender === currentUser ? 'end' : 'start'">
                        <MessageContent>
                          <MessageHeader v-if="item.sender !== currentUser">
                            {{ item.sender }}
                          </MessageHeader>
                          <Bubble :variant="messageVariant(item)">
                            <BubbleContent>{{ item.text }}</BubbleContent>
                          </Bubble>
                        </MessageContent>
                      </Message>
                    </MessageScrollerItem>
                    <MessageScrollerItem
                      v-else
                      :scroll-anchor="item.scrollAnchor"
                    >
                      <Marker variant="separator">
                        <MarkerContent>{{ item.text }}</MarkerContent>
                      </Marker>
                    </MessageScrollerItem>
                  </template>
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </MessageScrollerProvider>
        </CardContent>
        <CardFooter class="flex flex-col items-center gap-2 border-t">
          <Button
            type="button"
            :disabled="isComplete"
            class="w-full"
            variant="secondary"
            @click="advance"
          >
            {{ buttonLabel }}
          </Button>
          <p class="text-xs text-muted-foreground">
            {{ rockyTurn === 'idle'
              ? 'This will create a marker and make it the anchor'
              : 'Now send Rocky\'s reply into the conversation' }}
          </p>
        </CardFooter>
      </Card>
      <div class="mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-muted-foreground">
        When a user joins, a marker is created. scrollAnchor on the marker
        marks it as the next turn
      </div>
    </div>
  </MessageScrollerProvider>
</template>

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

New turns keep part of the previous reply in view.

I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.

That's the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll` — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.

The important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user's intent.

Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.
64px
Adjust the slider and send. Observe the previous message peek
<script setup lang="ts">
import {
  ArrowUpIcon,
  GlobeIcon,
  ImageIcon,
  PaperclipIcon,
  PlusIcon,
  RotateCwIcon,
  TelescopeIcon,
} from '@lucide/vue'
import { computed, ref } from 'vue'
import { createDemoChat, useDemoChat } from '@/lib/message-scroller-demo'
import { Button } from '@/components/ui/button'
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
} from '@/components/ui/input-group'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from '@/components/ui/message-scroller'
import { Slider } from '@/components/ui/slider'
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/ui/tooltip'

const DEFAULT_PEEK = 64

const chat = createDemoChat()
  .user('I\'m building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.')
  .assistant('That\'s the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll` — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\n\nThe important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user\'s intent.')
  .user('Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.')
  .assistant('MessageScrollerItem fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.\n\nIt also leaves a small peek of the previous exchange visible above the anchor, so context isn\'t lost. The reply starts in view without that disorienting jump you get from a plain overflow container.')
  .user('And if they\'ve scrolled up to re-read an older answer? I don\'t want to yank them back down.')
  .assistant('You won\'t. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out — their place in the thread stays put even as new tokens keep arriving below.\n\nWhen there is content they haven\'t seen yet, `MessageScrollerButton` appears at the bottom of the viewport. One tap jumps them back to the newest message and re-engages auto-scroll. Same pattern as Slack or iMessage: quiet when you\'re caught up, helpful when you\'re not.')
  .user('Last one — does this work with assistive tech?')
  .assistant('`MessageScrollerContent` sets `role="log"` and `aria-relevant="additions"` by default, so screen readers announce new messages as they stream in.\n\nThe scroll button is a real `<button>` with an sr-only label, and it\'s removed from the tab order when you\'re already at the bottom — no ghost focus stops.')

const { messages, status, nextMessage, send, reset } = useDemoChat(chat, {
  initialCount: 2,
  chunkDelayMs: 35,
})

const demoKey = ref(0)
const peek = ref(DEFAULT_PEEK)

const isBusy = computed(() => status.value === 'streaming')

function onSubmit() {
  if (!nextMessage.value || isBusy.value)
    return
  send()
}

function onReset() {
  reset()
  peek.value = DEFAULT_PEEK
  demoKey.value += 1
}

function onPeekChange(value: number[] | undefined) {
  const nextValue = Array.isArray(value) ? value[0] : value
  peek.value = nextValue ?? DEFAULT_PEEK
}
</script>

<template>
  <MessageScrollerProvider
    :key="demoKey"
    :scroll-margin="24"
    :scroll-previous-item-peek="peek"
  >
    <div class="relative flex flex-col gap-4">
      <Card class="mx-auto h-140 w-full max-w-sm gap-0">
        <CardHeader class="gap-1 border-b">
          <CardTitle>Keeping Context Visible</CardTitle>
          <CardDescription>
            New turns keep part of the previous reply in view.
          </CardDescription>
          <CardAction>
            <Tooltip>
              <TooltipTrigger as-child>
                <Button
                  variant="outline"
                  size="icon"
                  aria-label="Reset context example"
                  :disabled="isBusy"
                  @click="onReset"
                >
                  <RotateCwIcon />
                </Button>
              </TooltipTrigger>
              <TooltipContent>
                <p>Reset</p>
              </TooltipContent>
            </Tooltip>
          </CardAction>
        </CardHeader>
        <CardContent class="flex-1 overflow-hidden p-0">
          <MessageScroller>
            <MessageScrollerViewport>
              <MessageScrollerContent :aria-busy="isBusy" class="p-6">
                <MessageAnimated
                  v-for="message in messages"
                  :key="message.id"
                  :message="message"
                  :scroll-anchor="message.role === 'user'"
                />
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </CardContent>
        <CardFooter class="flex-col gap-2">
          <form class="w-full" @submit.prevent="onSubmit">
            <InputGroup>
              <div class="h-14 w-full px-3 py-2.5">
                <span
                  class="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
                  :data-status="status"
                >
                  <template v-if="nextMessage">{{ nextMessage.text }}</template>
                  <span v-else class="text-muted-foreground">
                    No messages queued. Reset the context.
                  </span>
                </span>
              </div>
              <InputGroupAddon align="block-end" class="pt-1">
                <DropdownMenu>
                  <DropdownMenuTrigger as-child>
                    <InputGroupButton
                      aria-label="Add files"
                      type="button"
                      size="icon-sm"
                      variant="outline"
                    >
                      <PlusIcon />
                    </InputGroupButton>
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="start" side="top" class="w-44">
                    <DropdownMenuItem>
                      <PaperclipIcon />
                      Add Photos &amp; Files
                    </DropdownMenuItem>
                    <DropdownMenuSeparator />
                    <DropdownMenuItem>
                      <ImageIcon />
                      Create Image
                    </DropdownMenuItem>
                    <DropdownMenuItem>
                      <TelescopeIcon />
                      Deep Research
                    </DropdownMenuItem>
                    <DropdownMenuItem>
                      <GlobeIcon />
                      Web Search
                    </DropdownMenuItem>
                  </DropdownMenuContent>
                </DropdownMenu>
                <div class="flex w-28 items-center gap-2">
                  <span class="text-xs text-muted-foreground tabular-nums">
                    {{ peek }}px
                  </span>
                  <Slider
                    aria-label="Previous context peek"
                    :model-value="[peek]"
                    :min="64"
                    :max="128"
                    :step="1"
                    :disabled="isBusy"
                    @update:model-value="onPeekChange"
                  />
                </div>
                <InputGroupButton
                  type="submit"
                  variant="default"
                  size="icon-sm"
                  class="ml-auto"
                  :disabled="!nextMessage || isBusy"
                >
                  <ArrowUpIcon />
                  <span class="sr-only">Send</span>
                </InputGroupButton>
              </InputGroupAddon>
            </InputGroup>
          </form>
        </CardFooter>
      </Card>
      <div class="px-0.5 text-center text-xs text-muted-foreground">
        Adjust the slider and send. Observe the previous message peek
      </div>
    </div>
  </MessageScrollerProvider>
</template>

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

Auto-scroll follows the live edge of the conversation.

Ready to Stream

Press send to stream a scripted launch summary.

I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.
Streaming is simulated. `autoScroll` is enabled.
<script setup lang="ts">
import {
  ArrowUpIcon,
  GlobeIcon,
  ImageIcon,
  MessageCircleDashedIcon,
  PaperclipIcon,
  PlusIcon,
  RotateCwIcon,
  TelescopeIcon,
} from '@lucide/vue'
import { computed } from 'vue'
import { createDemoChat, useDemoChat } from '@/lib/message-scroller-demo'
import { Button } from '@/components/ui/button'
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/components/ui/empty'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
} from '@/components/ui/input-group'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from '@/components/ui/message-scroller'
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/ui/tooltip'

const chat = createDemoChat()
  .user('I\'m building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.')
  .assistant('That\'s the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll` — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\n\nThe important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user\'s intent.')
  .user('Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.')
  .assistant('MessageScrollerItem fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.\n\nIt also leaves a small peek of the previous exchange visible above the anchor, so context isn\'t lost. The reply starts in view without that disorienting jump you get from a plain overflow container.')
  .user('And if they\'ve scrolled up to re-read an older answer? I don\'t want to yank them back down.')
  .assistant('You won\'t. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out — their place in the thread stays put even as new tokens keep arriving below.\n\nWhen there is content they haven\'t seen yet, `MessageScrollerButton` appears at the bottom of the viewport. One tap jumps them back to the newest message and re-engages auto-scroll. Same pattern as Slack or iMessage: quiet when you\'re caught up, helpful when you\'re not.')
  .user('Last one — does this work with assistive tech?')
  .assistant('`MessageScrollerContent` sets `role="log"` and `aria-relevant="additions"` by default, so screen readers announce new messages as they stream in.\n\nThe scroll button is a real `<button>` with an sr-only label, and it\'s removed from the tab order when you\'re already at the bottom — no ghost focus stops.')

const { messages, status, nextMessage, send, reset } = useDemoChat(chat, { chunkDelayMs: 20 })

const isBusy = computed(() => status.value === 'streaming')

function onSubmit() {
  if (!nextMessage.value || isBusy.value)
    return
  send()
}
</script>

<template>
  <MessageScrollerProvider auto-scroll>
    <div class="relative flex flex-col gap-4">
      <Card class="mx-auto h-140 w-full max-w-sm gap-0">
        <CardHeader class="gap-1 border-b">
          <CardTitle>Streaming Messages</CardTitle>
          <CardDescription>
            Auto-scroll follows the live edge of the conversation.
          </CardDescription>
          <CardAction>
            <Tooltip>
              <TooltipTrigger as-child>
                <Button
                  variant="outline"
                  size="icon"
                  aria-label="Reset stream"
                  :disabled="messages.length === 0 || isBusy"
                  @click="reset"
                >
                  <RotateCwIcon />
                </Button>
              </TooltipTrigger>
              <TooltipContent>
                <p>Reset</p>
              </TooltipContent>
            </Tooltip>
          </CardAction>
        </CardHeader>
        <CardContent class="flex-1 overflow-hidden p-0">
          <Empty v-if="messages.length === 0" class="h-full">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <MessageCircleDashedIcon />
              </EmptyMedia>
              <EmptyTitle>Ready to Stream</EmptyTitle>
              <EmptyDescription>
                Press send to stream a scripted launch summary.
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
          <MessageScroller v-else>
            <MessageScrollerViewport>
              <MessageScrollerContent :aria-busy="isBusy" class="p-6">
                <MessageAnimated
                  v-for="message in messages"
                  :key="message.id"
                  :message="message"
                  :scroll-anchor="message.role === 'user'"
                />
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </CardContent>
        <CardFooter class="flex-col gap-2">
          <form class="w-full" @submit.prevent="onSubmit">
            <InputGroup>
              <div class="h-14 w-full px-3 py-2.5">
                <span
                  class="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
                  :data-status="status"
                >
                  <template v-if="nextMessage">{{ nextMessage.text }}</template>
                  <span v-else class="text-muted-foreground">
                    No messages queued. Reset the stream.
                  </span>
                </span>
              </div>
              <InputGroupAddon align="block-end" class="pt-1">
                <DropdownMenu>
                  <DropdownMenuTrigger as-child>
                    <InputGroupButton
                      aria-label="Add files"
                      type="button"
                      size="icon-sm"
                      variant="outline"
                    >
                      <PlusIcon />
                    </InputGroupButton>
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="start" side="top" class="w-44">
                    <DropdownMenuItem>
                      <PaperclipIcon />
                      Add Photos &amp; Files
                    </DropdownMenuItem>
                    <DropdownMenuSeparator />
                    <DropdownMenuItem>
                      <ImageIcon />
                      Create Image
                    </DropdownMenuItem>
                    <DropdownMenuItem>
                      <TelescopeIcon />
                      Deep Research
                    </DropdownMenuItem>
                    <DropdownMenuItem>
                      <GlobeIcon />
                      Web Search
                    </DropdownMenuItem>
                  </DropdownMenuContent>
                </DropdownMenu>
                <InputGroupButton
                  type="submit"
                  variant="default"
                  size="icon-sm"
                  class="ml-auto"
                  :disabled="!nextMessage || isBusy"
                >
                  <ArrowUpIcon />
                  <span class="sr-only">Send</span>
                </InputGroupButton>
              </InputGroupAddon>
            </InputGroup>
          </form>
        </CardFooter>
      </Card>
      <div class="px-0.5 text-center text-xs text-muted-foreground">
        Streaming is simulated. `autoScroll` is enabled.
      </div>
    </div>
  </MessageScrollerProvider>
</template>

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

Choose where a saved transcript opens.

This is the first message the user sent in the conversation.

Workspace creation rose 8%, but first invite completion only rose 2%.

This is the last message the user sent in the conversation.

Start with the invite step. Teams are creating workspaces but waiting to add collaborators.

Recommended follow-up:

1. Compare invite drop-off by account size. 2. Check whether users who skip invites still return within 24 hours. 3. Review the empty-state copy on the first project screen. 4. Segment activation by template, since template users may not need invites right away.

If that pattern holds, the next experiment should make collaboration useful earlier instead of prompting for invites harder.

Toggle the defaultScrollPosition to see where the transcript starts when you open the thread
<script setup lang="ts">
import type { DemoMessage } from '@/lib/message-scroller-demo'
import { defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from '@/components/ui/message-scroller'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'

type Position = 'start' | 'end' | 'last-anchor'

const messages: DemoMessage[] = [
  {
    id: 'open-1',
    role: 'user',
    text: 'This is the first message the user sent in the conversation.',
  },
  {
    id: 'open-2',
    role: 'assistant',
    text: 'Workspace creation rose 8%, but first invite completion only rose 2%.',
  },
  {
    id: 'open-3',
    role: 'user',
    text: 'This is the last message the user sent in the conversation.',
  },
  {
    id: 'open-4',
    role: 'assistant',
    text: 'Start with the invite step. Teams are creating workspaces but waiting to add collaborators.\n\nRecommended follow-up:\n\n1. Compare invite drop-off by account size.\n2. Check whether users who skip invites still return within 24 hours.\n3. Review the empty-state copy on the first project screen.\n4. Segment activation by template, since template users may not need invites right away.\n\nIf that pattern holds, the next experiment should make collaboration useful earlier instead of prompting for invites harder.',
  },
]

const positions: { value: Position, label: string }[] = [
  { value: 'start', label: 'start' },
  { value: 'end', label: 'end' },
  { value: 'last-anchor', label: 'last-anchor' },
]

const position = ref<Position>('last-anchor')
const positionKey = ref(0)

function onValueChange(value: unknown) {
  if (value === 'start' || value === 'end' || value === 'last-anchor') {
    position.value = value
    positionKey.value += 1
  }
}

// useMessageScroller reads the scroller context, so it must live inside the
// provider. This inner component drives the opening scroll position and renders
// the transcript through its default slot.
const OpeningPositionScroller = defineComponent({
  name: 'OpeningPositionScroller',
  props: {
    position: { type: String as () => Position, required: true },
    positionKey: { type: Number, required: true },
  },
  setup(props, { slots }) {
    const { scrollToEnd, scrollToMessage, scrollToStart } = useMessageScroller()

    // Scroll positioning is client-only, so drive it from onMounted (which does
    // not run during SSR) plus a watch for later changes.
    let frame = 0
    function applyPosition() {
      cancelAnimationFrame(frame)
      frame = requestAnimationFrame(() => {
        if (props.position === 'start') {
          scrollToStart({ behavior: 'auto' })
          return
        }
        if (props.position === 'end') {
          scrollToEnd({ behavior: 'auto' })
          return
        }
        scrollToMessage('open-3', {
          align: 'start',
          behavior: 'auto',
          scrollMargin: 64,
        })
      })
    }

    onMounted(applyPosition)
    watch(() => [props.position, props.positionKey], applyPosition)
    onBeforeUnmount(() => cancelAnimationFrame(frame))

    return () => slots.default?.()
  },
})
</script>

<template>
  <div class="relative flex flex-col gap-4">
    <Card class="mx-auto h-140 w-full max-w-sm gap-0">
      <CardHeader class="gap-1 border-b">
        <CardTitle>Opening Position</CardTitle>
        <CardDescription>
          Choose where a saved transcript opens.
        </CardDescription>
      </CardHeader>
      <CardContent class="flex-1 overflow-hidden p-0">
        <MessageScrollerProvider>
          <OpeningPositionScroller :position="position" :position-key="positionKey">
            <MessageScroller>
              <MessageScrollerViewport>
                <MessageScrollerContent class="p-6">
                  <MessageAnimated
                    v-for="message in messages"
                    :key="message.id"
                    :message="message"
                    :scroll-anchor="message.role === 'user'"
                    user-variant="muted"
                    assistant-variant="ghost"
                  />
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </OpeningPositionScroller>
        </MessageScrollerProvider>
      </CardContent>
      <CardFooter class="flex items-center justify-center border-t">
        <Tabs
          :model-value="position"
          class="w-full"
          @update:model-value="onValueChange"
        >
          <TabsList class="w-full">
            <TabsTrigger
              v-for="option in positions"
              :key="option.value"
              :value="option.value"
            >
              {{ option.label }}
            </TabsTrigger>
          </TabsList>
        </Tabs>
      </CardFooter>
    </Card>
    <div class="mx-auto max-w-sm px-0.5 text-center text-xs text-muted-foreground">
      Toggle the defaultScrollPosition to see where the transcript starts when
      you open the thread
    </div>
  </div>
</template>

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.

Load History

Prepended messages keep your place.

Only the export queue worker changed. The deploy moved large CSV jobs onto the shared retry policy, which made each failed attempt hold a worker slot longer than before.

The app deploy did not include checkout, pricing, or billing API changes.

Do we need to roll back?

Not yet. Queue depth is recovering after we reduced retry concurrency, and the oldest pending job is now under five minutes old.

Keep rollback ready if the queue starts climbing again, but the current trend points toward recovery.

Keep watching for customer-visible issues.

I will watch the queue and support tags for another 15 minutes. I am tracking export failures, delayed download requests, and any support thread that mentions missing reports.

If those stay quiet through the next batch window, we can close this as an internal degradation.

End of Conversation

Restore earlier messages while keeping your place.

Click Load History to load the entire conversation
<script setup lang="ts">
import type { DemoMessage } from '@/lib/message-scroller-demo'
import { RotateCwIcon } from '@lucide/vue'
import { computed, ref } from 'vue'
import { toast } from 'vue-sonner'
import { createDemoChat } from '@/lib/message-scroller-demo'
import { Button } from '@/components/ui/button'
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Marker, MarkerContent } from '@/components/ui/marker'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from '@/components/ui/message-scroller'
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/ui/tooltip'

const chat = createDemoChat()
  .user('Can you summarize the incident channel?')
  .assistant(
    'The first alert was a delayed export job. It started backing up around 09:42 UTC and triggered the warning once the retry queue crossed the threshold.\n\nNo customer-facing checkout paths were affected, but exports for larger workspaces were running about 12 minutes behind.',
  )
  .user('Was checkout affected?')
  .assistant(
    'No checkout errors were reported. Payment authorization, order creation, and confirmation emails stayed inside their normal latency bands.\n\nThe only elevated metric was export queue depth, which maps to analytics downloads instead of checkout.',
  )
  .user('What changed in the last deploy?')
  .assistant(
    'Only the export queue worker changed. The deploy moved large CSV jobs onto the shared retry policy, which made each failed attempt hold a worker slot longer than before.\n\nThe app deploy did not include checkout, pricing, or billing API changes.',
  )
  .user('Do we need to roll back?')
  .assistant(
    'Not yet. Queue depth is recovering after we reduced retry concurrency, and the oldest pending job is now under five minutes old.\n\nKeep rollback ready if the queue starts climbing again, but the current trend points toward recovery.',
  )
  .user('Keep watching for customer-visible issues.')
  .assistant(
    'I will watch the queue and support tags for another 15 minutes. I am tracking export failures, delayed download requests, and any support thread that mentions missing reports.\n\nIf those stay quiet through the next batch window, we can close this as an internal degradation.',
  )

const history: DemoMessage[] = chat.turns.map((turn, index) => ({
  id: `history-${index}`,
  role: turn.role,
  text: turn.text,
}))
const INITIAL_VISIBLE_COUNT = 5

const demoKey = ref(0)
const messages = ref<DemoMessage[]>(history.slice(-INITIAL_VISIBLE_COUNT))

const canLoadHistory = computed(() => messages.value.length < history.length)
const canReset = computed(() => messages.value.length > INITIAL_VISIBLE_COUNT)

function loadHistory() {
  // Prepend the earlier messages to the top; preserveScrollOnPrepend keeps
  // the viewport anchored so the reader's place does not jump.
  const remaining = history.slice(0, history.length - messages.value.length)
  messages.value.unshift(...remaining)
  toast('History loaded', {
    description: 'Scroll up to see earlier messages.',
  })
}

function reset() {
  messages.value = history.slice(-INITIAL_VISIBLE_COUNT)
  demoKey.value += 1
}
</script>

<template>
  <MessageScrollerProvider>
    <div class="relative flex flex-col gap-4">
      <Card class="mx-auto h-140 w-full max-w-sm gap-0">
        <CardHeader class="gap-1 border-b">
          <CardTitle>Load History</CardTitle>
          <CardDescription>
            Prepended messages keep your place.
          </CardDescription>
          <CardAction>
            <Tooltip>
              <TooltipTrigger as-child>
                <Button
                  type="button"
                  variant="outline"
                  size="icon"
                  aria-label="Reset loaded messages"
                  :disabled="!canReset"
                  @click="reset"
                >
                  <RotateCwIcon />
                </Button>
              </TooltipTrigger>
              <TooltipContent>
                <p>Reset</p>
              </TooltipContent>
            </Tooltip>
          </CardAction>
        </CardHeader>
        <CardContent class="flex-1 overflow-hidden p-0">
          <MessageScroller :key="demoKey">
            <MessageScrollerViewport>
              <MessageScrollerContent class="p-6">
                <MessageAnimated
                  v-for="message in messages"
                  :key="message.id"
                  :message="message"
                  :scroll-anchor="message.role === 'user'"
                  user-variant="muted"
                  assistant-variant="ghost"
                />
                <MessageScrollerItem :scroll-anchor="false">
                  <Marker variant="separator">
                    <MarkerContent>End of Conversation</MarkerContent>
                  </Marker>
                </MessageScrollerItem>
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </CardContent>
        <CardFooter class="flex flex-col items-center gap-2 border-t">
          <Button
            type="button"
            :disabled="!canLoadHistory"
            class="w-full"
            variant="secondary"
            @click="loadHistory"
          >
            {{ canLoadHistory ? 'Load History' : 'History Loaded' }}
          </Button>
          <p class="text-xs text-muted-foreground">
            Restore earlier messages while keeping your place.
          </p>
        </CardFooter>
      </Card>
      <div class="mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-muted-foreground">
        Click Load History to load the entire conversation
      </div>
    </div>
  </MessageScrollerProvider>
</template>

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

Choose how user messages are animated when they are added to the conversation.

No Messages Yet

Click the button below to send the first message.

Select an animation then click send to see it in action.
<script setup lang="ts">
import type { MessageAnimationId } from '@/lib/message-scroller-demo'
import {
  ArrowUpIcon,
  MessageCircleDashedIcon,
  RotateCwIcon,
} from '@lucide/vue'
import { computed, ref } from 'vue'
import {
  createDemoChat,
  MESSAGE_ANIMATION_PRESETS,
  useDemoChat,
} from '@/lib/message-scroller-demo'
import { Button } from '@/components/ui/button'
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/components/ui/empty'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from '@/components/ui/message-scroller'
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'

const chat = createDemoChat()
  .user('Can user messages pop in like iMessage without breaking anchoring?')
  .sleep(1000)
  .assistant('Yes. Animate the user row with transform and opacity, and let the assistant response stream normally below it.\n\nThat keeps the row measurement predictable while still giving the newly sent bubble a more tactile entrance.')
  .user('What makes the animation feel more like iMessage?')
  .sleep(1000)
  .assistant('Use a quick spring from the trailing edge: a little scale, a small upward move, and no layout animation.\n\nThe bubble feels tactile, but the measured row stays predictable, so anchoring and auto-scroll do not have to fight a changing layout.')
  .user('Can I switch between presets while testing the same thread?')
  .sleep(1000)
  .assistant('Yes. Keep the conversation in place while you change the preset, then send the next message to compare the new entrance against the same context.\n\nThat makes it easier to judge the difference between a subtle fade, a snappy pop, and a more dramatic 3D tilt without rebuilding the scenario each time.')

const { messages, status, nextMessage, send, reset } = useDemoChat(chat, { chunkDelayMs: 15 })

const presetId = ref<MessageAnimationId>('fade')
const isBusy = computed(() => status.value === 'streaming')
const presetName = computed(
  () => MESSAGE_ANIMATION_PRESETS.find(preset => preset.id === presetId.value)?.name,
)

function onSend() {
  if (!nextMessage.value || isBusy.value)
    return
  send()
}
</script>

<template>
  <div class="relative flex flex-col gap-4">
    <Card class="mx-auto h-140 w-full max-w-sm gap-0">
      <CardHeader class="border-b">
        <CardTitle>Animation</CardTitle>
        <CardDescription>
          Choose how user messages are animated when they are added to the
          conversation.
        </CardDescription>
        <CardAction class="flex items-center gap-2">
          <Button
            type="button"
            variant="outline"
            size="icon"
            aria-label="Reset animated messages"
            :disabled="messages.length === 0 || isBusy"
            @click="reset"
          >
            <RotateCwIcon />
          </Button>
        </CardAction>
      </CardHeader>
      <CardContent class="min-h-0 flex-1 overflow-hidden p-0">
        <Empty v-if="messages.length === 0" class="h-full">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <MessageCircleDashedIcon />
            </EmptyMedia>
            <EmptyTitle>No Messages Yet</EmptyTitle>
            <EmptyDescription>
              Click the button below to send the first message.
            </EmptyDescription>
          </EmptyHeader>
        </Empty>
        <MessageScrollerProvider v-else>
          <MessageScroller>
            <MessageScrollerViewport>
              <MessageScrollerContent :aria-busy="isBusy" class="p-6">
                <MessageAnimated
                  v-for="message in messages"
                  :key="message.id"
                  :message="message"
                  :animation-preset="presetId"
                  user-variant="muted"
                  assistant-variant="ghost"
                />
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </MessageScrollerProvider>
      </CardContent>
      <CardFooter class="border-t">
        <Select v-model="presetId">
          <SelectTrigger aria-label="Animation preset">
            <SelectValue>{{ presetName }}</SelectValue>
          </SelectTrigger>
          <SelectContent align="start" side="top">
            <SelectGroup>
              <SelectItem
                v-for="animation in MESSAGE_ANIMATION_PRESETS"
                :key="animation.id"
                :value="animation.id"
              >
                {{ animation.name }}
              </SelectItem>
            </SelectGroup>
          </SelectContent>
        </Select>
        <Button
          type="button"
          size="icon"
          class="ml-auto"
          :disabled="!nextMessage || isBusy"
          @click="onSend"
        >
          <ArrowUpIcon />
          <span class="sr-only">Send Message</span>
        </Button>
      </CardFooter>
    </Card>
    <div class="mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-muted-foreground">
      Select an animation then click send to see it in action.
    </div>
  </div>
</template>

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 composables read from MessageScrollerProvider, so they work in any component inside the provider.

<script setup lang="ts">
import { useMessageScroller } from '@/components/ui/message-scroller'

const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller()
</script>

Commands

Drive the transcript from outside.

We're seeing activation dip after workspace creation. Can you help me find the likely step?

The sharpest drop is between creating the workspace and inviting the first teammate.

Workspace creation is still healthy, but the invite step is where users pause. That suggests the product is asking for collaboration before the user has enough confidence in the workspace.

What should I compare before we change the onboarding flow?

Compare three cohorts:

1. Users who choose a template before inviting teammates. 2. Users who start from a blank workspace. 3. Users who skip invites and return within 24 hours.

If template users invite faster, the fix is probably better first-run guidance rather than a louder invite prompt.

Can you turn that into an experiment?

Yes. Create a variant that shows a short checklist after workspace creation:

- Pick a template. - Add one project detail. - Invite a teammate when the workspace has context.

Measure first invite completion, 24-hour return rate, and whether teams create a second project.

What's the risk if we delay the invite prompt?

The main risk is reducing team creation for accounts that already know who they want to invite.

To protect that path, keep the invite action visible in the header and only change the primary empty-state guidance. That gives confident teams a direct route without forcing uncertain users through the invite step too early.

Use the controls to jump to any message in the conversation.
<script setup lang="ts">
import type { DemoMessage } from '@/lib/message-scroller-demo'
import { defineComponent, h } from 'vue'
import { Button } from '@/components/ui/button'
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from '@/components/ui/message-scroller'

const messages: DemoMessage[] = [
  {
    id: 'command-activation',
    role: 'user',
    text: 'We\'re seeing activation dip after workspace creation. Can you help me find the likely step?',
  },
  {
    id: 'command-activation-reply',
    role: 'assistant',
    text: 'The sharpest drop is between creating the workspace and inviting the first teammate.\n\nWorkspace creation is still healthy, but the invite step is where users pause. That suggests the product is asking for collaboration before the user has enough confidence in the workspace.',
  },
  {
    id: 'command-compare',
    role: 'user',
    text: 'What should I compare before we change the onboarding flow?',
  },
  {
    id: 'command-compare-reply',
    role: 'assistant',
    text: 'Compare three cohorts:\n\n1. Users who choose a template before inviting teammates.\n2. Users who start from a blank workspace.\n3. Users who skip invites and return within 24 hours.\n\nIf template users invite faster, the fix is probably better first-run guidance rather than a louder invite prompt.',
  },
  {
    id: 'command-experiment',
    role: 'user',
    text: 'Can you turn that into an experiment?',
  },
  {
    id: 'command-experiment-reply',
    role: 'assistant',
    text: 'Yes. Create a variant that shows a short checklist after workspace creation:\n\n- Pick a template.\n- Add one project detail.\n- Invite a teammate when the workspace has context.\n\nMeasure first invite completion, 24-hour return rate, and whether teams create a second project.',
  },
  {
    id: 'command-risk',
    role: 'user',
    text: 'What\'s the risk if we delay the invite prompt?',
  },
  {
    id: 'command-risk-reply',
    role: 'assistant',
    text: 'The main risk is reducing team creation for accounts that already know who they want to invite.\n\nTo protect that path, keep the invite action visible in the header and only change the primary empty-state guidance. That gives confident teams a direct route without forcing uncertain users through the invite step too early.',
  },
]

const userMessages = messages.filter(message => message.role === 'user')

function getTrimmedMessageText(message: DemoMessage) {
  const text = message.text
  return text.length > 42 ? `${text.slice(0, 39)}...` : text
}

// The menu drives the transcript via `scrollToMessage`, so it must be rendered
// inside the provider where `useMessageScroller` is available.
const CommandMenu = defineComponent({
  name: 'CommandMenu',
  setup() {
    const { scrollToMessage } = useMessageScroller()
    return () =>
      h(DropdownMenu, null, () => [
        h(DropdownMenuTrigger, { asChild: true }, () =>
          h(Button, { type: 'button', variant: 'secondary' }, () => 'Jump to...')),
        h(DropdownMenuContent, { align: 'end', side: 'bottom', class: 'w-64' }, () =>
          h(DropdownMenuGroup, null, () => [
            h(DropdownMenuLabel, null, () => 'Conversations'),
            ...userMessages.map(message =>
              h(
                DropdownMenuItem,
                {
                  key: message.id,
                  onClick: () =>
                    scrollToMessage(message.id, {
                      align: 'start',
                      behavior: 'smooth',
                    }),
                },
                () => h('span', { class: 'line-clamp-1 min-w-0' }, getTrimmedMessageText(message)),
              )),
          ])),
      ])
  },
})
</script>

<template>
  <MessageScrollerProvider default-scroll-position="end">
    <div class="relative flex flex-col gap-4">
      <Card class="mx-auto h-140 w-full max-w-sm gap-0">
        <CardHeader class="gap-1 border-b">
          <CardTitle>Commands</CardTitle>
          <CardDescription>
            Drive the transcript from outside.
          </CardDescription>
          <CardAction>
            <CommandMenu />
          </CardAction>
        </CardHeader>
        <CardContent class="flex-1 overflow-hidden p-0">
          <MessageScroller>
            <MessageScrollerViewport>
              <MessageScrollerContent class="p-6">
                <MessageAnimated
                  v-for="message in messages"
                  :key="message.id"
                  :message="message"
                  :scroll-anchor="message.role === 'user'"
                  user-variant="muted"
                  assistant-variant="ghost"
                />
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </CardContent>
      </Card>
      <div class="mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-muted-foreground">
        Use the controls to jump to any message in the conversation.
      </div>
    </div>
  </MessageScrollerProvider>
</template>

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

Track the current anchored turn.

Review the incident handoff and tell me what to read first.

Start with the summary and the impact section. The regression affected the upload queue, but the recovery path completed for every queued job.

What was the customer impact?

Impact was limited to delayed processing.

No records were dropped, and the reconciliation worker confirmed each retry batch. Support saw confusion from two customers, but there were no checkout or billing errors.

What actions are open?

Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.

The alert should fire on sustained queue growth, not a single short spike.

Give me the follow-up checklist.

After that, compare the queue recovery graph with the deploy timeline so the handoff shows exactly when processing returned to baseline. That makes it easier for support and engineering to answer the same customer questions without re-reading the whole incident thread.

I would also add a short owner note beside each follow-up item. The checklist is small, but ownership keeps the retry-window decision, alert tuning, and support macro from drifting into separate follow-up conversations.

Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.

The alert should fire on sustained queue growth, not a single short spike.

Open the outline to jump between anchored turns as you read.
<script setup lang="ts">
import type { DemoMessage } from '@/lib/message-scroller-demo'
import { defineComponent, h } from 'vue'
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from '@/components/ui/hover-card'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
  useMessageScrollerVisibility,
} from '@/components/ui/message-scroller'

const messages: DemoMessage[] = [
  {
    id: 'vis-brief',
    role: 'user',
    text: 'Review the incident handoff and tell me what to read first.',
  },
  {
    id: 'vis-brief-reply',
    role: 'assistant',
    text: 'Start with the summary and the impact section. The regression affected the upload queue, but the recovery path completed for every queued job.',
  },
  {
    id: 'vis-impact',
    role: 'user',
    text: 'What was the customer impact?',
  },
  {
    id: 'vis-impact-reply',
    role: 'assistant',
    text: 'Impact was limited to delayed processing.\n\nNo records were dropped, and the reconciliation worker confirmed each retry batch. Support saw confusion from two customers, but there were no checkout or billing errors.',
  },
  {
    id: 'vis-actions',
    role: 'user',
    text: 'What actions are open?',
  },
  {
    id: 'vis-actions-reply',
    role: 'assistant',
    text: 'Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.\n\nThe alert should fire on sustained queue growth, not a single short spike.',
  },
  {
    id: 'vis-checklist',
    role: 'user',
    text: 'Give me the follow-up checklist.',
  },
  {
    id: 'vis-checklist-reply',
    role: 'assistant',
    text: 'After that, compare the queue recovery graph with the deploy timeline so the handoff shows exactly when processing returned to baseline. That makes it easier for support and engineering to answer the same customer questions without re-reading the whole incident thread.\n\nI would also add a short owner note beside each follow-up item. The checklist is small, but ownership keeps the retry-window decision, alert tuning, and support macro from drifting into separate follow-up conversations.\n\nKeep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.\n\nThe alert should fire on sustained queue growth, not a single short spike.',
  },
]

const userMessages = messages.filter(message => message.role === 'user')

function getTrimmedMessageText(message: DemoMessage) {
  const text = message.text
  return text.length > 42 ? `${text.slice(0, 39)}...` : text
}

// The outline reads visibility state and scrolls the viewport, so it must live
// inside the provider. Render it with h() to keep the composables inside the
// MessageScroller context.
const TranscriptOutline = defineComponent({
  name: 'TranscriptOutline',
  setup() {
    const { scrollToMessage } = useMessageScroller()
    const visibility = useMessageScrollerVisibility()

    return () => {
      const currentAnchorId = visibility.value.currentAnchorId

      return h(HoverCard, null, {
        default: () => [
          h(HoverCardTrigger, { asChild: true }, {
            default: () =>
              h(
                'button',
                {
                  'type': 'button',
                  'aria-label': 'Open transcript outline',
                  'class': 'flex h-9 w-9 flex-col items-center justify-center gap-1 rounded-md transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
                },
                userMessages.map(message =>
                  h('span', {
                    'key': message.id,
                    'data-current': message.id === currentAnchorId,
                    'class': 'h-0.5 w-4 rounded-full bg-muted-foreground/40 data-[current=true]:bg-foreground',
                  }),
                ),
              ),
          }),
          h(
            HoverCardContent,
            {
              align: 'center',
              side: 'left',
              sideOffset: -28,
              class: 'flex w-64 flex-col gap-1 rounded-2xl p-1',
            },
            {
              default: () =>
                userMessages.map(message =>
                  h(
                    'button',
                    {
                      'key': message.id,
                      'type': 'button',
                      'aria-current': currentAnchorId === message.id ? 'location' : undefined,
                      'class': 'flex min-h-7 items-center rounded-xl px-2 py-1.5 text-left text-sm transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-current:bg-accent aria-current:text-accent-foreground',
                      'onClick': () =>
                        scrollToMessage(message.id, {
                          align: 'start',
                          behavior: 'smooth',
                        }),
                    },
                    h('span', { class: 'line-clamp-1 min-w-0' }, getTrimmedMessageText(message)),
                  ),
                ),
            },
          ),
        ],
      })
    }
  },
})
</script>

<template>
  <MessageScrollerProvider :scroll-margin="12">
    <div class="relative flex flex-col gap-4">
      <div class="relative mx-auto w-full max-w-sm">
        <Card class="h-140 w-full gap-0">
          <CardHeader class="gap-1 border-b">
            <CardTitle>Transcript Outline</CardTitle>
            <CardDescription>
              Track the current anchored turn.
            </CardDescription>
          </CardHeader>
          <CardContent class="flex-1 overflow-hidden p-0">
            <MessageScroller>
              <MessageScrollerViewport>
                <MessageScrollerContent class="p-6">
                  <MessageAnimated
                    v-for="message in messages"
                    :key="message.id"
                    :message="message"
                    :scroll-anchor="message.role === 'user'"
                    user-variant="muted"
                    assistant-variant="ghost"
                  />
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </CardContent>
        </Card>
        <div class="absolute top-1/2 -right-12 -translate-y-1/2">
          <TranscriptOutline />
        </div>
      </div>
      <div class="mx-auto max-w-sm px-0.5 text-center text-xs text-muted-foreground">
        Open the outline to jump between anchored turns as you read.
      </div>
    </div>
  </MessageScrollerProvider>
</template>

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

Where the reader can scroll to based on current scroll position.

Review scroll checkpoint 1.

Checkpoint 2 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 3.

Checkpoint 4 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 5.

Checkpoint 6 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 7.

Checkpoint 8 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 9.

Checkpoint 10 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 11.

Checkpoint 12 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

All messages fit in the viewport.
Scroll the transcript to see the footer update.
<script setup lang="ts">
import type { DemoMessage } from '@/lib/message-scroller-demo'
import { defineComponent, h } from 'vue'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScrollerScrollable,
} from '@/components/ui/message-scroller'

const messages: DemoMessage[] = Array.from({ length: 12 }, (_, index) => ({
  id: `scrollable-${index + 1}`,
  role: index % 2 === 0 ? 'user' : 'assistant',
  text:
    index % 2 === 0
      ? `Review scroll checkpoint ${index + 1}.`
      : `Checkpoint ${index + 1} is synced. The scrollable hook updates as the viewport moves.\n\nWhen the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.\n\nAt the latest message, the footer should switch again and only point them back up.`,
}))

function getScrollStatus({ start, end }: { start: boolean, end: boolean }) {
  if (start && end)
    return 'You can scroll both ways.'
  if (end)
    return 'You are at the top. You can only scroll down.'
  if (start)
    return 'You are at the bottom. You can only scroll up.'
  return 'All messages fit in the viewport.'
}

// The footer reads the scrollable state, so it must live inside the provider.
const ScrollStateFooter = defineComponent({
  name: 'ScrollStateFooter',
  setup() {
    const scrollable = useMessageScrollerScrollable()
    return () =>
      h(
        CardFooter,
        { class: 'justify-center border-t text-center text-sm text-muted-foreground' },
        () => getScrollStatus(scrollable.value),
      )
  },
})
</script>

<template>
  <div class="mx-auto flex w-full max-w-sm flex-col gap-4">
    <Card class="h-140 w-full gap-0 overflow-hidden">
      <CardHeader class="gap-1 border-b">
        <CardTitle>Scroll Status</CardTitle>
        <CardDescription>
          Where the reader can scroll to based on current scroll position.
        </CardDescription>
      </CardHeader>
      <MessageScrollerProvider default-scroll-position="start">
        <CardContent class="flex-1 overflow-hidden p-0">
          <MessageScroller>
            <MessageScrollerViewport>
              <MessageScrollerContent class="gap-4 p-6">
                <MessageAnimated
                  v-for="message in messages"
                  :key="message.id"
                  :message="message"
                  :scroll-anchor="message.role === 'user'"
                  user-variant="muted"
                  assistant-variant="ghost"
                />
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </CardContent>
        <ScrollStateFooter />
      </MessageScrollerProvider>
    </Card>
    <div class="px-0.5 text-center text-xs text-muted-foreground">
      Scroll the transcript to see the footer update.
    </div>
  </div>
</template>

API Reference

MessageScrollerProvider

Owns the scroll state and behavior. Provide it via provide/inject and expose the scroll composables to descendants.

PropTypeDefaultDescription
autoScrollbooleanfalseFollow the live edge while the reader is pinned to the bottom.
defaultScrollPosition'start' | 'end' | 'last-anchor''end'Opening position for the transcript.
scrollEdgeThresholdnumber8Distance in px from an edge before it is considered scrollable.
scrollPreviousItemPeeknumber64Amount in px of the previous turn kept visible when anchoring.
scrollMarginnumber0Extra offset in px applied when scrolling to an element.

MessageScrollerViewport

PropTypeDefaultDescription
preserveScrollOnPrependbooleantrueKeep the current view when messages are added above.

Rendered as a role="region", aria-label="Messages", focusable (tabindex="0") native scroll container.

MessageScrollerItem

PropTypeDefaultDescription
messageIdstringStable id used for anchoring, visibility, jumps.
scrollAnchorbooleanfalseMarks this row as the start of a turn.

MessageScrollerButton

PropTypeDefaultDescription
direction'start' | 'end''end'Direction the button scrolls toward.
behaviorScrollBehavior'smooth'Scroll behavior for the jump.
variantButtonVariants'secondary'Button variant.
sizeButtonVariants'icon-sm'Button size.

Exposes data-active for styling and becomes inert with tabindex="-1" when there is nothing to scroll toward.

Composables

useMessageScroller()

const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller()
  • scrollToMessage(id, options?) — scroll to the item with the matching messageId. Returns true if handled (queued if the item is not mounted yet), false if 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.