Message Scroller

Follows new messages until the reader scrolls up.

Can you plan a hike for this weekend?
Sure — any distance in mind, and who's coming?
Around 10 km, just me and Sam.
Got it. Saturday looks dry; I'll pull a couple of loops near the coast.
Saturday works. What's the plan?

Scroll up mid-stream to release, then jump back.

Installation

pnpm dlx shadcn@latest add @paneui/message-scroller

Usage

Examples

Pill Labels

Custom Jump Control

Live Edge Callback

Start at Top

No Follow

Keyboard

The viewport is focusable. ArrowUp, PageUp, Home and Shift+Space release it from the live edge; End jumps back to the latest message.

Motion

Under reduced motion the viewport jumps instead of gliding to the bottom, and the pill fades instead of blurring and lifting.

API Reference

MessageScroller

A conversation viewport pinned to the live edge while output streams. Also accepts every prop of div.

threshold= 48
number
Distance from the bottom (px) still treated as at the live edge.
startAtEnd= true
boolean
Start scrolled to the bottom on mount.
follow= true
boolean
Follow new content while at the live edge.
showJumpButton= true
boolean
Show the floating jump-to-latest pill when released.
jumpButton= "pill"
"pill" | "icon"
icon renders a round arrow-only button (label kept for screen readers), as in desktop chat apps.
jumpLabel= "Jump to latest"
string
Pill label when nothing new is below.
newLabel= "New messages"
string
Pill label once content arrived below the reader.
viewportClassName
string
Classes for the scrolling viewport.
contentClassName
string
Classes for the content column inside the viewport (default flex flex-col p-4).
onLiveEdgeChange
(atLiveEdge: boolean) => void
Called when the viewport attaches to or releases the live edge.
footer
ReactNode
Rendered below the viewport, outside the scrolling content (a composer, a jump bar). Can call useMessageScroller().
overlay
ReactNode
Rendered over the viewport, outside the scrolling content, in a relative frame; position it yourself. Can call useMessageScroller().
PropTypeDefaultDescription
thresholdnumber48Distance from the bottom (px) still treated as at the live edge.
startAtEndbooleantrueStart scrolled to the bottom on mount.
followbooleantrueFollow new content while at the live edge.
showJumpButtonbooleantrueShow the floating jump-to-latest pill when released.
jumpButton"pill" | "icon""pill"icon renders a round arrow-only button (label kept for screen readers), as in desktop chat apps.
jumpLabelstring"Jump to latest"Pill label when nothing new is below.
newLabelstring"New messages"Pill label once content arrived below the reader.
viewportClassNamestring—Classes for the scrolling viewport.
contentClassNamestring—Classes for the content column inside the viewport (default flex flex-col p-4).
onLiveEdgeChange(atLiveEdge: boolean) => void—Called when the viewport attaches to or releases the live edge.
footerReactNode—Rendered below the viewport, outside the scrolling content (a composer, a jump bar). Can call useMessageScroller().
overlayReactNode—Rendered over the viewport, outside the scrolling content, in a relative frame; position it yourself. Can call useMessageScroller().
data-live-edge
On the root while pinned to the newest content.
data-new
On the jump pill once new content arrived below.
Data attributeDescription
data-live-edgeOn the root while pinned to the newest content.
data-newOn the jump pill once new content arrived below.

useMessageScroller

Reads the nearest MessageScroller from its children, footer or overlay. Throws outside one.

atLiveEdge
boolean
Returned. True while pinned to the newest content.
hasNew
boolean
Returned. True once content arrived below a reader who scrolled up.
scrollToLatest
(behavior?: ScrollBehavior) => void
Returned. Scroll to the newest content and re-attach; "smooth" by default.
PropTypeDefaultDescription
atLiveEdgeboolean—Returned. True while pinned to the newest content.
hasNewboolean—Returned. True once content arrived below a reader who scrolled up.
scrollToLatest(behavior?: ScrollBehavior) => void—Returned. Scroll to the newest content and re-attach; "smooth" by default.