MessageScroller
A scrolling conversation that opens at the newest message, follows new ones, and offers a jump back.
A message scroller holds a conversation that grows at the end. It opens at the newest message,
follows new messages while the reader is at the end (with auto), stays where it is when the
reader scrolled up to read, and then shows a button to jump back. It is built on the headless message scroller of
@shadcn/.
// apps/docs/examples/ui/message-scroller.tsx'use client';
import {useState} from 'react';
import { Bubble, BubbleContent,} from '@workspace/active-ui/components/primitives/bubble';import {Button} from '@workspace/active-ui/components/primitives/button';import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from '@workspace/active-ui/components/primitives/message-scroller';
const START = Array.from({length: 12}, (_, index) => ({ id: `m${index + 1}`, text: `Status update ${index + 1}`, mine: index % 3 === 2,}));
/** * A conversation that scrolls: it opens at the newest message, follows new * messages while you are at the end (`autoScroll`), and shows a button to jump back when * you have scrolled up. New messages are added by the button below (no * server: they are made in the browser). */export default function MessageScrollerExample() { const [messages, setMessages] = useState(START); const add = () => setMessages((current) => [ ...current, { id: `m${current.length + 1}`, text: `Status update ${current.length + 1}`, mine: false, }, ]); return ( <div className="flex w-full max-w-md flex-col gap-3"> <MessageScrollerProvider autoScroll> <MessageScroller className="h-72 rounded-lg border"> <MessageScrollerViewport aria-label="Conversation"> <MessageScrollerContent className="gap-3 p-4"> {messages.map((message) => ( <MessageScrollerItem key={message.id} messageId={message.id}> <Bubble variant={message.mine ? 'default' : 'muted'} align={message.mine ? 'end' : 'start'} > <BubbleContent>{message.text}</BubbleContent> </Bubble> </MessageScrollerItem> ))} </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton label="Jump to the newest message" /> </MessageScroller> </MessageScrollerProvider> <Button variant="outline" className="self-start" onClick={add}> Add a message </Button> </div> );}Usage#
import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from '@workspace/active-ui/components/primitives/message-scroller';
<MessageScrollerProvider autoScroll> <MessageScroller className="h-96"> <MessageScrollerViewport aria-label="Conversation"> <MessageScrollerContent> {messages.map((message) => ( <MessageScrollerItem key={message.id} messageId={message.id}> โฆ </MessageScrollerItem> ))} </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton label="Jump to the newest message" /> </MessageScroller></MessageScrollerProvider>;Anatomy#
Message: the scroll state.Scroller Provider auto(defaultScroll false) follows new messages while the reader is at the end,default(Scroll Position end,startorlast-) chooses where it opens, andanchor scrollhow close to the end counts as "at the end".Edge Threshold Message: the frame; give it a height.Scroller Message: the scrolling region,Scroller Viewport role="region";preservekeeps the position when older messages load above.Scroll On Prepend Message: the list,Scroller Content role="log", a polite live region for new messages.Message: one message;Scroller Item messagelets you jump to it, andId scrollmarks the message to open at withAnchor last-.anchor Message: the jump button, shown only when the reader is away from that end;Scroller Button directionend(default) orstart.
The hooks#
Three hooks read and move the scroll position, for controls of your own beside the built-in jump
button. They work inside Message only; outside it they throw.
usereturnsMessage Scroller() scroll,To Start(options?) scrollandTo End(options?) scroll. The options areTo Message(message Id, options?) behavior(autoby default, orsmooth),align(startby default,center,endornearest; forscroll) andTo Message scrollin pixels. Each returnsMargin truewhen it scrolled or scheduled the scroll,falsewhen there is nothing to scroll yet (no viewport) or no message with that id is rendered. A jump to a message that has not rendered yet, while the list is still empty, waits for it.usereturns aMessage Scroller Scrollable() Message,Scroller Scrollable {start, end}: whether there is more content above and below the visible part, beyondscroll(8 px by default). WhileEdge Threshold autofollows new messages,Scroll endisfalse. Use it to enable or hide your own buttons, asMessagedoes.Scroller Button usereturns aMessage Scroller Visibility() Message,Scroller Visibility State {visible: theMessage Ids, current Anchor Id} messages of the items in view, in order, and the lastId scrollitem whose top has scrolled past the top of the viewport (Anchor nullif none). "In view" starts below a top band ofscrollplusMargin scroll(64 px by default), so a message that only peeks in at the top does not count. It watches the items only while a component uses the hook, and the first state arrives a frame after it mounts.Previous Item Peek
No message counted as in view yet. More above: no. More below: no.
// apps/docs/examples/ui/message-scroller-hooks.tsx'use client';
import { Bubble, BubbleContent,} from '@workspace/active-ui/components/primitives/bubble';import {Button} from '@workspace/active-ui/components/primitives/button';import { MessageScroller, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility,} from '@workspace/active-ui/components/primitives/message-scroller';
const MESSAGES = Array.from({length: 20}, (_, index) => ({ id: `m${index + 1}`, text: `Status update ${index + 1}`,}));
/** * The three hooks, inside the provider: buttons that scroll with * `useMessageScroller`, and a line that reads `useMessageScrollerScrollable` * (is there more above or below?) and `useMessageScrollerVisibility` (which * messages are in view). Hooks outside `MessageScrollerProvider` throw. */function Controls() { const {scrollToStart, scrollToEnd, scrollToMessage} = useMessageScroller(); const {start, end} = useMessageScrollerScrollable(); const {visibleMessageIds} = useMessageScrollerVisibility(); const first = visibleMessageIds[0]?.slice(1); const last = visibleMessageIds.at(-1)?.slice(1); return ( <div className="flex flex-col gap-2"> <div className="flex flex-wrap gap-2"> <Button variant="outline" size="sm" disabled={!start} onClick={() => scrollToStart({behavior: 'smooth'})} > First message </Button> <Button variant="outline" size="sm" onClick={() => scrollToMessage('m10', {align: 'center'})} > Message 10 </Button> <Button variant="outline" size="sm" disabled={!end} onClick={() => scrollToEnd({behavior: 'smooth'})} > Newest message </Button> </div> <p className="text-muted-foreground text-sm" data-message-scroller-state> {first && last ? `Messages ${first} to ${last} of ${MESSAGES.length} counted as in view.` : 'No message counted as in view yet.'}{' '} More above: {start ? 'yes' : 'no'}. More below: {end ? 'yes' : 'no'}. </p> </div> );}
export default function MessageScrollerHooksExample() { return ( <div className="flex w-full max-w-md flex-col gap-3"> <MessageScrollerProvider defaultScrollPosition="start"> <Controls /> <MessageScroller className="h-60 rounded-lg border"> <MessageScrollerViewport aria-label="Status updates"> <MessageScrollerContent className="gap-3 p-4"> {MESSAGES.map((message) => ( <MessageScrollerItem key={message.id} messageId={message.id}> <Bubble variant="muted" align="start"> <BubbleContent>{message.text}</BubbleContent> </Bubble> </MessageScrollerItem> ))} </MessageScrollerContent> </MessageScrollerViewport> </MessageScroller> </MessageScrollerProvider> </div> );}What your application provides#
A name for the viewport (
aria-), andlabel labelof an icon jump button (required).The messages, with a stable
message, and loading older ones when the reader reaches the start.Id
Accessibility and keyboard#
The viewport is a focusable region: the arrow keys,
Page Up,Page Down,HomeandEndscroll it.The content is a log: new messages are announced politely.
API#
MessageScrollerProvider
No props of its own.
Also takes the props of @shadcn/react, unchanged and documented there: auto, children, default, scroll, scroll, scroll.
Takes only the props listed.
MessageScrollerViewport
No props of its own.
Also takes the props of @shadcn/react, unchanged and documented there: preserve.
Other props, such as the standard attributes of the underlying element, className and ref, are passed through.
MessageScrollerContent
No props of its own.
Also takes the props of @shadcn/react, unchanged and documented there: spacer.
Other props, such as the standard attributes of the underlying element, className and ref, are passed through.
MessageScrollerItem
No props of its own.
Also takes the props of @shadcn/react, unchanged and documented there: message, scroll.
Other props, such as the standard attributes of the underlying element, className and ref, are passed through.
MessageScrollerButton
Props
Also takes the props of @shadcn/react, unchanged and documented there: behavior, direction, render.
Other props, such as the standard attributes of the underlying element, className and ref, are passed through.
Parts that take only their elementโs attributes
Message: the attributes of its element, with className and ref.Scroller
Functions, hooks and constants
() => { scrollToEnd: (options?: MessageScrollerScrollOptions) => boolean; scrollToMessage: (messageId: string, options?: MessageScrollerScrollOptions) => boolean; scrollToStart: (options?: MessageScrollerScrollOptions) => boolean; }Returns
An object with these fields.
- Type
(options?) => boolean
- Type
(messageId, options?) => boolean
- Type
(options?) => boolean
- Type
() => MessageScroller Scrollable
- Type
() => MessageScroller Visibility State
Returns
An object with these fields.
- Type
string | null
- Type
string[]