Summit
ComponentsConversation

Message Scroller

A Message Scroller allows users to scroll a conversation, which opens at the newest message and moves to each new turn.

When to Use

  • for a conversation that is taller than its panel and grows while it is open
  • to open a saved conversation at its newest message or, with defaultScrollPosition="last-anchor", at its last turn
  • when a reply arrives in pieces and the viewport should follow it, with autoScroll
  • to jump to one message from a search result or a link

When Not to Use

  • for any other list that is longer than its panel (use a Scroll Area)
  • for a few messages that always fit (stack them in a div)

A Message Scroller is a client component, and its viewport adds a stop to the tab order.

Anatomy

Does that change the estimate?
No. It stays at 40 hours.
Good. When is the next invoice due?
INV-2026-014 is due on October 27.
ElementUsage
ViewportRequired
ItemRequired
Scroll buttonOptional

MessageScrollerProvider keeps the scroll state and renders no element. MessageScroller is the frame, MessageScrollerViewport is the element that scrolls, and MessageScrollerContent stacks the rows. Place every row in a MessageScrollerItem, whether it contains a Message, a Marker, or a Marker rendered as a button that loads earlier messages.

See Message for the place of the Message Scroller among the other parts of a conversation.

Behavior

MessageScroller fills its parent in both directions, so set the height on the parent.

DoSet a height on the parent so that the conversation scrolls inside it and opens at its newest message.
Don’tWhen the parent has no height, the frame grows with the conversation, nothing scrolls inside it, and users have to scroll the page instead.

Opening Position

To set where the conversation opens, pass defaultScrollPosition to the provider. The value is read once, when the first rows mount.

ValueResult
'end'The newest message, at the bottom of the viewport. This is the default.
'start'The first message
'last-anchor'The last row with scrollAnchor, near the top of the viewport. It falls back to 'end' when no row is an anchor or when everything from that row down fits in the viewport.

New Turns

A row with scrollAnchor starts a turn. In the preview, each message that the current user sends has scrollAnchor. When such a row is added at the end, the viewport scrolls it near the top, and the end of the previous row stays in view. The rest of the viewport is free for the reply. The viewport scrolls in this way wherever users have scrolled to.

If autoScroll isn’t set, a row that has no scrollAnchor doesn’t scroll the viewport. The row is added below the viewport’s bottom edge, and the scroll button appears.

Following a Reply

If autoScroll is set on the provider, the end stays in view while rows without an anchor arrive or the last row grows. The viewport follows only while users are at the end. When users scroll with the wheel, by touch, or with a key, or jump to a message, it stops following. When they return to the end, by scrolling or with the scroll button, it follows again.

Earlier Messages

By default, rows that are added above the first one don’t move the conversation because the viewport keeps the first visible row in place. To turn this off, set preserveScrollOnPrepend={false} on the viewport. Set a stable messageId on every row so that the Message Scroller can find the row again.

Scroll Button

MessageScrollerButton appears while more than 8px of the conversation is below the viewport, and when users click it, the viewport scrolls there. At direction="start", it is positioned at the top and scrolls to the first message. While there is nothing to scroll to, it is inert and out of the tab order.

The scroll is smooth. When users request reduced motion, the viewport jumps instead. To make it jump for everyone, set behavior="auto" on the button.

Long Conversations

MessageScrollerItem sets content-visibility: auto, so the browser skips the rendering of a row that is off screen. The row stays in the markup, so users can still reach it with the browser’s find and with a screen reader.

Content

StringDefaultSource
Name of the scroll button“Scroll to end”, “Scroll to start”labels.messageScroller in the Summit Provider
Name of the viewport“Messages”labels.messageScroller in the Summit Provider

Accessibility

The viewport is a named region that is focusable, so keyboard users can scroll the conversation with the browser’s scrolling keys. The content is a log, so screen readers announce each row as it is added.

Set aria-busy on MessageScrollerContent while a reply is still arriving, so that screen readers wait for the finished row. See the shadcn Message Scroller documentation for the reason.

Installation

npx shadcn@latest add @summit/message-scroller

The CLI also adds @summit/button and @summit/summit-provider.

Usage

The Message Scroller doesn’t store messages. The product keeps the list, renders a row for each entry, and adds the new ones.

import { Bubble, BubbleContent } from '@/components/ui/bubble';
import { Message, MessageContent } from '@/components/ui/message';
import {
    MessageScroller,
    MessageScrollerButton,
    MessageScrollerContent,
    MessageScrollerItem,
    MessageScrollerProvider,
    MessageScrollerViewport,
} from '@/components/ui/message-scroller';

<div className="h-80">
    <MessageScrollerProvider>
        <MessageScroller>
            <MessageScrollerViewport>
                <MessageScrollerContent className="p-4">
                    {messages.map((message) => (
                        <MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor={message.mine}>
                            <Message align={message.mine ? 'end' : 'start'}>
                                <MessageContent>
                                    <Bubble variant={message.mine ? 'default' : 'secondary'}>
                                        <BubbleContent>{message.text}</BubbleContent>
                                    </Bubble>
                                </MessageContent>
                            </Message>
                        </MessageScrollerItem>
                    ))}
                </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
        </MessageScroller>
    </MessageScrollerProvider>
</div>;

Follow a Reply

Set autoScroll on the provider.

Follow a Reply
<MessageScrollerProvider autoScroll>
    <MessageScroller />
</MessageScrollerProvider>

Open at the Last Turn

Set defaultScrollPosition="last-anchor".

Open at the Last Turn
<MessageScrollerProvider defaultScrollPosition="last-anchor">
    <MessageScroller />
</MessageScrollerProvider>

Jump to a Message

useMessageScroller returns scrollToMessage, scrollToEnd, and scrollToStart. You can call it in any component inside the provider, so the control can be placed outside the frame.

Jump to a Message
import { Button } from '@/components/ui/button';
import { useMessageScroller } from '@/components/ui/message-scroller';

function JumpToEstimate() {
    const { scrollToMessage } = useMessageScroller();

    return (
        <Button variant="outline" size="sm" onClick={() => scrollToMessage('m4', { align: 'start' })}>
            The estimate
        </Button>
    );
}

Read the Scroll State

useMessageScrollerScrollable returns start and end, which are true while content extends past the matching edge.

Read the Scroll State
import { useMessageScrollerScrollable } from '@/components/ui/message-scroller';

function EarlierMessagesHint() {
    const { start } = useMessageScrollerScrollable();

    return start ? <p>There are earlier messages.</p> : null;
}

Scroll to the Start

Add a second button with direction="start".

Scroll to the Start
<MessageScroller>
    <MessageScrollerViewport />
    <MessageScrollerButton direction="start" />
    <MessageScrollerButton />
</MessageScroller>

Name the Viewport

Pass aria-label to the viewport.

Name the Viewport
<MessageScrollerViewport aria-label="Conversation with Elise Martin" />

API Reference

The parts accept the props of their matching parts in @shadcn/react, and the hooks are that library’s own. See the @shadcn/react Message Scroller documentation for scrollEdgeThreshold, scrollMargin, scrollPreviousItemPeek, and the options of scrollToMessage.

HookReturns
useMessageScrollerscrollToMessage, scrollToEnd, and scrollToStart
useMessageScrollerScrollablestart and end, which say whether content extends past each edge
useMessageScrollerVisibilitycurrentAnchorId and visibleMessageIds, for the rows that have a messageId

MessageScrollerProvider

PropTypeDefaultDescription
autoScrollbooleanfalseFollows new content while users are at the end
defaultScrollPosition'start' | 'end' | 'last-anchor''end'The position at which the conversation opens

MessageScrollerItem

PropTypeDefaultDescription
messageIdstringNoneThe id that scrollToMessage and the visibility hook refer to
scrollAnchorbooleanfalseMarks the row as the start of a turn

MessageScrollerButton

PropTypeDefaultDescription
direction'start' | 'end''end'The edge that the button is positioned at and scrolls to
behaviorScrollBehavior'smooth'How the viewport scrolls when users click the button
variantA Button variant'secondary'Passed to the Button. At the default, the Message Scroller’s own classes replace its fill, its border, and its text color.
sizeA Button size'icon-sm'The size of the button

Children replace the arrow and its label.

On this page