Morpha UI

Message Scroller

A chat viewport with stick-to-bottom autoscroll, anchoring and visibility tracking.

Overview

The conversation viewport: it sticks to the live edge while new messages stream in, lets go the moment the reader scrolls away, and offers a tonal IconButton to jump back to the latest message. Turns can be anchored so a long answer unrolls beneath the question that produced it, older history can be prepended without moving the reader, and hooks expose the scroll API, the visible turns and the scrollable state.

The frame reads on the kit's conversation voice — type-body in on-surface, the same scale as Message and Bubble — and the scrollbar is tinted with the outline-variant hairline so it recedes into the surface. Items are content-visibility-contained, so long histories stay cheap to render. Built on the @shadcn/react message-scroller primitive.

Can you summarise the release notes?
You · 09:40
Three changes shipped: a new date picker, tonal chips, and a faster table.
Morpha · 09:41
Which one is breaking?
You · 09:42
Only the table — its `data` prop must now be a stable reference.
Morpha · 09:43
Got it. What about the chips?
You · 09:44
Purely visual: the filter chip gained an animated leading check.
Morpha · 09:45
And the date picker?
You · 09:46
It composes Popover + Calendar, so it inherits the kit focus ring.
Morpha · 09:47
Anything for the docs?
You · 09:48
Every component page now ships live previews on the Morpha tokens.
Morpha · 09:49
$ npx morpha-ui add message-scroller

Examples

New chat

An empty thread, a composer, and a streamed answer. autoScroll keeps the live edge in view while the response is written; the empty state sits in the same viewport, so nothing shifts when the first turn lands.

Ask me anything

The viewport sticks to the live edge while the answer streams.

Anchoring turns

scrollAnchor on the user's turn parks it near the top of the viewport instead of the bottom, so the assistant's answer unrolls underneath the question rather than pushing it off screen.

How does anchoring work?
An anchored item is scrolled near the top of the viewport instead of the bottom, so a long answer unrolls underneath the question that produced it. The reader keeps their place at the start of the turn while the rest of the text fills the frame below — exactly how a long-form assistant answer wants to be read.

Each question is a `scrollAnchor` — it parks at the top of the frame.

Group chat

Several participants, with Marker day separators doubling as the scroll anchors — jumping through the thread lands on a day, not mid-sentence.

Monday
A
Ada
Pushed the tonal chips — the check animation is in.
09:12
G
Grace
Saw it. The blur crossfade is lovely on the filter chip.
09:14
Merging after the docs land.
09:15
Tuesday
A
Ada
Docs are up — every page has a live preview now.
10:02
G
Grace
The message scroller page could use a group chat example.
10:05
On it — markers double as the scroll anchors.
10:06

Keeping context visible

scrollPreviousItemPeek leaves a sliver of the previous turn on screen when the next one is anchored, so the thread never looks like it jumped to a blank page. Toggle it off to see the anchored question land flush against the top edge.

Question 1: keep the context visible?
With a peek, the tail of the previous turn stays on screen when the new question is anchored — so the thread never looks like it jumped to a blank page. Without it, the anchored question lands flush against the top edge and the context above it disappears.

Following the live edge

While a response streams, the viewport follows it. Scroll up mid-stream and autoscroll releases immediately — the reader always wins — and the jump-to-latest button slides in from the bottom edge.

Autoscroll follows the live edge while the answer is written.

Scroll up mid-stream: autoscroll releases and the button appears.

Opening saved threads

defaultScrollPosition decides where a reopened conversation lands: at the top (start), on the newest message (end), or on the last question you asked (last-anchor).

Question 1 — where should a saved thread open?
Saved thread line 2. Reopening a conversation is a positioning decision, not a scroll animation.
Saved thread line 3. Reopening a conversation is a positioning decision, not a scroll animation.
Question 2 — where should a saved thread open?
Saved thread line 5. Reopening a conversation is a positioning decision, not a scroll animation.
Saved thread line 6. Reopening a conversation is a positioning decision, not a scroll animation.
Question 3 — where should a saved thread open?
Saved thread line 8. Reopening a conversation is a positioning decision, not a scroll animation.
Saved thread line 9. Reopening a conversation is a positioning decision, not a scroll animation.
Question 4 — where should a saved thread open?
Saved thread line 11. Reopening a conversation is a positioning decision, not a scroll animation.
Saved thread line 12. Reopening a conversation is a positioning decision, not a scroll animation.

Opens on the last question you asked.

Load history

With preserveScrollOnPrepend (the default), prepending an older page of messages keeps the reader exactly where they were — the content grows above the viewport instead of under it.

Message 20 — older history, prepended above without moving the reader.
Message 21 — older history, prepended above without moving the reader.
Message 22 — older history, prepended above without moving the reader.
Message 23 — older history, prepended above without moving the reader.
Message 24 — older history, prepended above without moving the reader.
Message 25 — older history, prepended above without moving the reader.
Message 26 — older history, prepended above without moving the reader.
Message 27 — older history, prepended above without moving the reader.

Animating new messages

New turns arrive on the kit's motion: a few pixels of rise plus a soft blur that clears, on one strong ease-out curve. Nothing appears from nothing, and prefers-reduced-motion keeps the fade while dropping the travel.

New messages enter with the kit motion: a 4px rise plus a blur that clears.

Commands

useMessageScroller() exposes scrollToStart, scrollToEnd and scrollToMessage(id, options) — drive the viewport from any control inside the provider.

Message 1 of the transcript.
Message 2 of the transcript.
Message 3 of the transcript.
Message 4 of the transcript.
Message 5 of the transcript.
Message 6 of the transcript.
Pinned: this is the message the "Jump to pinned" command targets.
Message 8 of the transcript.
Message 9 of the transcript.
Message 10 of the transcript.
Message 11 of the transcript.
Message 12 of the transcript.
Message 13 of the transcript.
Message 14 of the transcript.
Message 15 of the transcript.
Message 16 of the transcript.

Transcript outline

useMessageScrollerVisibility() reports the currentAnchorId and the visible message ids: enough to keep a side outline in sync with the transcript, and to scroll back to a section when it is clicked.

Tokens
This turn is long enough to fill the viewport on its own, so exactly one section is the current anchor at any time. Scroll the transcript and the outline follows.
Typography
This turn is long enough to fill the viewport on its own, so exactly one section is the current anchor at any time. Scroll the transcript and the outline follows.
Motion
This turn is long enough to fill the viewport on its own, so exactly one section is the current anchor at any time. Scroll the transcript and the outline follows.
Accessibility
This turn is long enough to fill the viewport on its own, so exactly one section is the current anchor at any time. Scroll the transcript and the outline follows.

Scroll status

useMessageScrollerScrollable() tells you which directions still have content — useful for an "unread below" line, or to confirm the whole thread is in view.

Message 1 — the status line below reads the scrollable state.
Message 2 — the status line below reads the scrollable state.
Message 3 — the status line below reads the scrollable state.
Message 4 — the status line below reads the scrollable state.
Message 5 — the status line below reads the scrollable state.
Message 6 — the status line below reads the scrollable state.
Message 7 — the status line below reads the scrollable state.
Message 8 — the status line below reads the scrollable state.
Message 9 — the status line below reads the scrollable state.
Message 10 — the status line below reads the scrollable state.
Message 11 — the status line below reads the scrollable state.
Message 12 — the status line below reads the scrollable state.
Message 13 — the status line below reads the scrollable state.
Message 14 — the status line below reads the scrollable state.
Whole thread in view

Installation

Morpha UI components are distributed via the Morpha CLI: npx morpha-ui add message-scroller. In the meantime, since this is the base shadcn component, you can also add it directly:

npx shadcn@latest add message-scroller

API Reference

Anatomy

<MessageScrollerProvider autoScroll>
  <MessageScroller>
    <MessageScrollerViewport>
      <MessageScrollerContent>
        <MessageScrollerItem messageId={id} scrollAnchor={role === 'user'}>
          <Message />
        </MessageScrollerItem>
      </MessageScrollerContent>
    </MessageScrollerViewport>
    <MessageScrollerButton />
  </MessageScroller>
</MessageScrollerProvider>

MessageScrollerProvider

Owns scroll state, anchoring, autoscroll and visibility tracking for everything inside it. The hooks only work under a provider.

Prop

Type

MessageScroller

The frame: a relatively positioned column that clips its own overflow and hosts the scroll buttons. It has no props of its own — a plain div that accepts className plus any native attribute.

MessageScrollerViewport

The scrolling element.

Prop

Type

MessageScrollerContent

The transcript container — a live region with a trailing spacer that lets the last anchored turn reach the top of the viewport.

Prop

Type

MessageScrollerItem

One row of the transcript.

Prop

Type

MessageScrollerButton

The jump-to-edge affordance, rendered as the kit's tonal IconButton.

Prop

Type

Hooks

  • useMessageScroller()scrollToStart(options), scrollToEnd(options), scrollToMessage(id, options). Options: align, behavior, scrollMargin.
  • useMessageScrollerVisibility(){ currentAnchorId, visibleMessageIds }.
  • useMessageScrollerScrollable(){ start, end }: whether content remains in each direction.

Accessibility

  • The viewport is a normal scroll container — keyboard scrolling and screen-reader reading order are preserved, and it takes the kit's secondary focus ring when tabbed to.
  • The content is a polite live region: streamed additions are announced without interrupting the reader.
  • The scroll-to-edge button has a visually hidden label and leaves the tab order when inactive.
  • Autoscroll stops the moment the user scrolls — never fight the reader's position.
  • Give every row a stable messageId when you drive the viewport from an outline or from commands: that id is what those controls scroll to.

On this page