Message Scroller
A chat transcript scroller that anchors turns, follows streamed replies at the live edge, preserves place when history loads, and jumps to any message.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748import { Message } from "#components/ui/ark-ui/message.tsx";import { MessageScroller, useMessageScroller,} from "#components/ui/ark-ui/message-scroller.tsx";
// Composition// MessageScroller.Provider// └── MessageScroller.Root// ├── MessageScroller.Viewport// │ └── MessageScroller.Content// │ └── MessageScroller.Item (message, marker, or row)// └── MessageScroller.Button
export function Example() { return ( // Provider owns opening position, turn anchoring, and follow-output. // Place the frame in a height-constrained parent. Mark turn starts // with scrollAnchor so new exchanges settle near the top. <div style={{ height: "24rem" }}> <MessageScroller.Provider autoScroll defaultScrollPosition="last-anchor"> <MessageScroller.Root> <MessageScroller.Viewport> <MessageScroller.Content> {messages.map((message) => ( <MessageScroller.Item key={message.id} messageId={message.id} scrollAnchor={message.role === "user"} > <Message.Root align={message.role === "user" ? "end" : "start"}> <Message.Content> {/* Bubble surface */} </Message.Content> </Message.Root> </MessageScroller.Item> ))} </MessageScroller.Content> </MessageScroller.Viewport> <MessageScroller.Button /> </MessageScroller.Root> </MessageScroller.Provider> </div> );}
// Drive the transcript from outside the list:// const { scrollToMessage, scrollToEnd } = useMessageScroller();Anchoring Turns
Mark a row with scrollAnchor so a new exchange settles near the top. Anchors are not tied to role — toggle which message owns the turn.
1234567891011121314151617181920212223242526import { Message } from "#components/ui/ark-ui/message.tsx";import { MessageScroller } from "#components/ui/ark-ui/message-scroller.tsx";
export function Example() { return ( <MessageScroller.Provider autoScroll={false} defaultScrollPosition="last-anchor"> <MessageScroller.Root> <MessageScroller.Viewport> <MessageScroller.Content> {messages.map((message) => ( <MessageScroller.Item key={message.id} messageId={message.id} scrollAnchor={message.role === "user"} > <Message.Root align={message.role === "user" ? "end" : "start"}> <Message.Content>{/* Bubble surface */}</Message.Content> </Message.Root> </MessageScroller.Item> ))} </MessageScroller.Content> </MessageScroller.Viewport> </MessageScroller.Root> </MessageScroller.Provider> );}Keeping Context Visible
scrollPreviousItemPeek keeps a slice of the previous item above the new anchor so the thread does not feel like it restarted on a blank page.
1234567891011121314151617import { MessageScroller } from "#components/ui/ark-ui/message-scroller.tsx";
export function Example() { return ( <MessageScroller.Provider autoScroll={false} defaultScrollPosition="last-anchor" scrollPreviousItemPeek={64} > <MessageScroller.Root> <MessageScroller.Viewport> <MessageScroller.Content>{/* items */}</MessageScroller.Content> </MessageScroller.Viewport> </MessageScroller.Root> </MessageScroller.Provider> );}Following the Live Edge
autoScroll follows streamed replies only while the reader stays at the live edge. Scrolling away — wheel, touch, keys, or the scrollbar — releases the view.
1234567891011121314151617181920import { MessageScroller } from "#components/ui/ark-ui/message-scroller.tsx";
export function Example() { return ( <MessageScroller.Provider autoScroll defaultScrollPosition="end"> <MessageScroller.Root> <MessageScroller.Viewport> <MessageScroller.Content> {messages.map((message) => ( <MessageScroller.Item key={message.id} messageId={message.id}> {/* streamed reply */} </MessageScroller.Item> ))} </MessageScroller.Content> </MessageScroller.Viewport> <MessageScroller.Button /> </MessageScroller.Root> </MessageScroller.Provider> );}Opening Position
defaultScrollPosition controls where a saved thread lands. last-anchor opens on the last meaningful turn; start and end land at the edges.
123456789101112131415import { MessageScroller } from "#components/ui/ark-ui/message-scroller.tsx";
export function Example() { return ( <MessageScroller.Provider defaultScrollPosition="last-anchor"> <MessageScroller.Root> <MessageScroller.Viewport> <MessageScroller.Content>{/* saved thread */}</MessageScroller.Content> </MessageScroller.Viewport> </MessageScroller.Root> </MessageScroller.Provider> );}
// defaultScrollPosition="start" | "end" | "last-anchor"Loading Earlier Messages
When older rows are prepended, the viewport preserves the visible row so history loads above the reader. Enabled by default via preserveScrollOnPrepend.
12345678910111213141516171819import { MessageScroller } from "#components/ui/ark-ui/message-scroller.tsx";
export function Example() { return ( <MessageScroller.Provider preserveScrollOnPrepend> <MessageScroller.Root> <MessageScroller.Viewport> <MessageScroller.Content> {messages.map((message) => ( <MessageScroller.Item key={message.id} messageId={message.id}> {/* history prepends above the reader */} </MessageScroller.Item> ))} </MessageScroller.Content> </MessageScroller.Viewport> </MessageScroller.Root> </MessageScroller.Provider> );}Jumping to Messages
useMessageScroller exposes scrollToMessage, scrollToStart, and scrollToEnd from any descendant of the provider — including controls outside the frame.
123456789101112131415161718192021222324252627import { MessageScroller, useMessageScroller,} from "#components/ui/ark-ui/message-scroller.tsx";
function JumpToLatest() { const { scrollToMessage, scrollToStart, scrollToEnd } = useMessageScroller(); return ( <button type="button" onClick={() => scrollToMessage("m-42")}> Jump to m-42 </button> );}
export function Example() { return ( <MessageScroller.Provider> <JumpToLatest /> <MessageScroller.Root> <MessageScroller.Viewport> <MessageScroller.Content>{/* items */}</MessageScroller.Content> </MessageScroller.Viewport> <MessageScroller.Button /> </MessageScroller.Root> </MessageScroller.Provider> );}Tracking the Reader
useMessageScrollerVisibility reports the current anchored turn and the message ids on screen. Tracking only runs while something subscribes.
1234567891011121314151617181920212223242526import { MessageScroller, useMessageScrollerVisibility,} from "#components/ui/ark-ui/message-scroller.tsx";
function ReaderStatus() { const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility(); return ( <span> Anchored on {currentAnchorId ?? "none"} · {visibleMessageIds.length} in view </span> );}
export function Example() { return ( <MessageScroller.Provider> <ReaderStatus /> <MessageScroller.Root> <MessageScroller.Viewport> <MessageScroller.Content>{/* items */}</MessageScroller.Content> </MessageScroller.Viewport> </MessageScroller.Root> </MessageScroller.Provider> );}Reading Scroll State
useMessageScrollerScrollable reports which edges still have content. The jump-to-latest button uses the same signal via data-scrollable.
12345678910111213141516171819202122232425import { MessageScroller, useMessageScrollerScrollable,} from "#components/ui/ark-ui/message-scroller.tsx";
function EdgeStatus() { const { start, end } = useMessageScrollerScrollable(); if (!start && !end) return <span>The transcript fits in view.</span>; if (start && end) return <span>You can scroll both ways.</span>; return <span>{start ? "More messages above." : "More messages below."}</span>;}
export function Example() { return ( <MessageScroller.Provider> <MessageScroller.Root> <MessageScroller.Viewport> <MessageScroller.Content>{/* items */}</MessageScroller.Content> </MessageScroller.Viewport> <MessageScroller.Button /> </MessageScroller.Root> <EdgeStatus /> </MessageScroller.Provider> );}Install
1npx stylexblocks add message-scrollerSource
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364import * as stylex from "@stylexjs/stylex";import { ark, type HTMLArkProps } from "@ark-ui/react/factory";import { useLayoutEffect, useRef } from "react";import { MessageScrollerProvider, useMessageScroller, useMessageScrollerContext, useMessageScrollerScrollable, useMessageScrollerVisibility, type MessageScrollerProviderProps,} from "#lib/message-scroller.tsx";import { type WithStyleOverrides } from "#lib/recipe-props.ts";import { durations } from '@stylexblocks/ui/tokens/durations.stylex.ts'import { easings } from '@stylexblocks/ui/tokens/easings.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { semanticShadows } from '@stylexblocks/ui/semantic-tokens/shadows.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'
const messageScrollerRoot = stylex.create({ base: { display: 'flex', position: 'relative', flexDirection: 'column', overflow: 'hidden', width: '100%', height: '100%', minHeight: 0, },})
const messageScrollerViewport = stylex.create({ base: { width: '100%', height: '100%', minWidth: 0, minHeight: 0, overflowY: 'auto', overflowX: 'hidden', overscrollBehavior: 'contain', scrollbarWidth: 'thin', scrollbarGutter: 'stable', visibility: { default: 'visible', ':is([data-pending-scroll])': 'hidden', }, },})
const messageScrollerContent = stylex.create({ base: { display: 'flex', flexDirection: 'column', gap: spacing._8, width: '100%', minHeight: '100%', height: 'max-content', },})
const messageScrollerItem = stylex.create({ base: { minWidth: 0, flexShrink: 0, },})
const messageScrollerButton = stylex.create({ base: { position: 'absolute', insetInlineStart: '50%', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: sizes.controlSm, height: sizes.controlSm, borderRadius: semanticRadii.control, borderWidth: '1px', borderStyle: 'solid', borderColor: semanticColors.border, backgroundColor: { default: semanticColors.bgPanel, ':hover': semanticColors.bgMuted, }, color: semanticColors.fg, boxShadow: semanticShadows.sm, cursor: { default: 'pointer', ':is([data-active="false"])': 'default', }, pointerEvents: { default: 'auto', ':is([data-active="false"])': 'none', }, transform: 'translateX(-50%)', transitionProperty: 'transform, opacity', transitionDuration: durations.moderate, transitionTimingFunction: easings.easeOut, opacity: { default: 1, ':is([data-active="false"])': 0, }, outlineWidth: { default: null, ':focus-visible': '2px', }, outlineOffset: { default: null, ':focus-visible': '2px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': semanticColors.accentFocusRing, }, },})
const messageScrollerButtonDirections = stylex.create({ end: { bottom: spacing._4, transform: { default: 'translateX(-50%)', ':is([data-active="false"])': 'translateX(-50%) translateY(100%)', }, }, start: { top: spacing._4, transform: { default: 'translateX(-50%)', ':is([data-active="false"])': 'translateX(-50%) translateY(-100%)', }, },})
export type MessageScrollerButtonDirection = keyof typeof messageScrollerButtonDirections
function mergeClassName(base: string | undefined, extra: unknown) { return typeof extra === "string" && extra.length > 0 ? [base, extra].filter(Boolean).join(" ") : base;}
function ArrowIcon() { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" > <path d="M8 3.5v9M4.5 9.5 8 13l3.5-3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> );}
function Provider(props: MessageScrollerProviderProps) { return <MessageScrollerProvider {...props} />;}Provider.displayName = "MessageScrollerProvider";
type RootProps = WithStyleOverrides<HTMLArkProps<"div">>;
function Root({ palette, style, className, ...rest }: RootProps) { const api = useMessageScrollerContext(); const ref = useRef<HTMLDivElement | null>(null); useLayoutEffect(() => api.registerRoot(ref.current), [api]); const p = stylex.props(palette, messageScrollerRoot.base, style); return ( <ark.div ref={ref} data-slot="message-scroller" {...rest} className={mergeClassName(p.className, className)} style={p.style} /> );}Root.displayName = "MessageScrollerRoot";
type ViewportProps = WithStyleOverrides<HTMLArkProps<"div">>;
function Viewport({ palette, style, className, ...rest }: ViewportProps) { const api = useMessageScrollerContext(); const ref = useRef<HTMLDivElement | null>(null); useLayoutEffect(() => api.registerViewport(ref.current), [api]); const p = stylex.props(palette, messageScrollerViewport.base, style); return ( <ark.div ref={ref} role="region" tabIndex={0} aria-label="Messages" data-slot="message-scroller-viewport" {...rest} className={mergeClassName(p.className, className)} style={p.style} /> );}Viewport.displayName = "MessageScrollerViewport";
type ContentProps = WithStyleOverrides<HTMLArkProps<"div">>;
const spacerStyle = stylex.create({ spacer: { flexShrink: 0, height: 0, pointerEvents: "none", },});
function Content({ palette, style, className, children, ...rest }: ContentProps) { const api = useMessageScrollerContext(); const ref = useRef<HTMLDivElement | null>(null); const spacer = useRef<HTMLDivElement | null>(null); useLayoutEffect(() => api.registerContent(ref.current), [api]); useLayoutEffect(() => api.registerSpacer(spacer.current), [api]); const p = stylex.props(palette, messageScrollerContent.base, style); return ( <ark.div ref={ref} role="log" aria-relevant="additions" data-slot="message-scroller-content" {...rest} className={mergeClassName(p.className, className)} style={p.style} > {children} <div ref={spacer} aria-hidden="true" data-slot="message-scroller-spacer" {...stylex.props(spacerStyle.spacer)} /> </ark.div> );}Content.displayName = "MessageScrollerContent";
type ItemProps = WithStyleOverrides<HTMLArkProps<"div">> & { messageId?: string; scrollAnchor?: boolean; };
function Item({ messageId, scrollAnchor = false, palette, style, className, ...rest}: ItemProps) { const api = useMessageScrollerContext(); const ref = useRef<HTMLDivElement | null>(null); useLayoutEffect( () => api.registerItem(ref.current, { messageId, scrollAnchor }), [api, messageId, scrollAnchor], ); const p = stylex.props(palette, messageScrollerItem.base, style); return ( <ark.div ref={ref} data-slot="message-scroller-item" data-message-id={messageId} data-scroll-anchor={scrollAnchor ? "" : undefined} {...rest} className={mergeClassName(p.className, className)} style={p.style} /> );}Item.displayName = "MessageScrollerItem";
const buttonLocal = stylex.create({ startIcon: { transform: "rotate(180deg)" },});
type ButtonProps = WithStyleOverrides<HTMLArkProps<"button">> & { direction?: MessageScrollerButtonDirection; };
function Button({ direction = "end", palette, style, className, children, ...rest}: ButtonProps) { const { scrollToEnd, scrollToStart } = useMessageScroller(); const scrollable = useMessageScrollerScrollable(); const active = direction === "end" ? scrollable.end : scrollable.start; const p = stylex.props( palette, messageScrollerButton.base, messageScrollerButtonDirections[direction], style, ); return ( <ark.button type="button" data-slot="message-scroller-button" data-direction={direction} data-active={active ? "true" : "false"} aria-label={ direction === "end" ? "Scroll to latest messages" : "Scroll to start" } tabIndex={active ? 0 : -1} inert={active ? undefined : true} onClick={() => (direction === "end" ? scrollToEnd() : scrollToStart())} {...rest} className={mergeClassName(p.className, className)} style={p.style} > {children ?? (direction === "start" ? ( <span {...stylex.props(buttonLocal.startIcon)}> <ArrowIcon /> </span> ) : ( <ArrowIcon /> ))} </ark.button> );}Button.displayName = "MessageScrollerButton";
/** * MessageScroller — chat transcript scroller. The provider owns opening * position, turn anchoring, follow-output, prepend restore, and jump * commands. Styled parts fill a height-constrained parent. Hooks read * scroll state from any descendant of the provider. */export const MessageScroller = { Provider, Root, Viewport, Content, Item, Button,};
export { useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility,};
export type { MessageScrollerProviderProps };
Inspired by the Message Scroller component from shadcn/ui.