Message scroller
A conversation viewport that sticks to the newest message and offers a jump back down.
- Status
- Experimental
- Level
- Organism
- Category
- Layout
- Adoption
- Not used yet
import { MessageScroller } from "@oration/canon/components/message-scroller";packages/canon/src/components/message-scroller.tsximport { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from "@oration/canon/components/message-scroller";import * as React from "react";export function Hero() { const id = React.useId(); const [draft, setDraft] = React.useState("Draft a note to Tomás about it"); const [streaming, setStreaming] = React.useState(false); const [messages, setMessages] = React.useState< { id: string; role: "user" | "assistant"; text: string }[] >([ { id: "m1", role: "user", text: "Why is INV-20417 from Northwind Freight on hold?", }, { id: "m2", role: "assistant", text: "It doesn't match PO-88213. The PO covers 40 pallets at $456 each and the invoice bills 42. Receiving logged 40 on Sep 21, so two pallets are unconfirmed.", }, { id: "m3", role: "user", text: "Who usually signs off on freight overages?", }, { id: "m4", role: "assistant", text: "Tomás Ferreira approved the last three freight overages under $2,000. This one is $912, so it's within his limit.", }, ]); const timer = React.useRef<number | null>(null); React.useEffect( () => () => { if (timer.current !== null) window.clearInterval(timer.current); }, [], ); const send = () => { const text = draft.trim(); if (!text || streaming) return; const words = "Here's a draft for Tomás: INV-20417 from Northwind Freight bills 42 pallets against PO-88213, and receiving logged 40 on Sep 21. Can you approve the $912 difference or should we short-pay it? I've attached the receiving log and the invoice.".split( " ", ); const stamp = Date.now(); const replyId = `a-${stamp}`; setDraft(""); setStreaming(true); setMessages((current) => [ ...current, { id: `u-${stamp}`, role: "user", text }, { id: replyId, role: "assistant", text: "" }, ]); let count = 0; timer.current = window.setInterval(() => { count += 1; setMessages((current) => current.map((message) => message.id === replyId ? { ...message, text: words.slice(0, count).join(" ") } : message, ), ); if (count >= words.length && timer.current !== null) { window.clearInterval(timer.current); timer.current = null; setStreaming(false); } }, 70); }; return ( <div className="flex h-[26rem] w-full max-w-xl flex-col overflow-hidden rounded-xl bg-card text-left shadow-border"> <div className="relative min-h-0 flex-1"> <MessageScrollerProvider autoScroll> <MessageScroller> <MessageScrollerViewport aria-label="Conversation about INV-20417"> <MessageScrollerContent aria-busy={streaming || undefined} className="p-4" > {messages.map((message) => ( <MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor={message.role === "user"} > {message.role === "user" ? ( <p className="ml-auto w-fit max-w-[80%] rounded-xl bg-muted/70 px-3 py-2 text-sm"> {message.text} </p> ) : ( <p className="max-w-[90%] text-sm text-pretty"> {message.text || ( <span className="text-muted-foreground"> Thinking </span> )} </p> )} </MessageScrollerItem> ))} </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton /> </MessageScroller> </MessageScrollerProvider> </div> <form onSubmit={(event) => { event.preventDefault(); send(); }} className="flex items-center gap-2 border-t border-border p-3" > <label htmlFor={`${id}-ask`} className="sr-only"> Ask about this invoice </label> <Input id={`${id}-ask`} value={draft} onChange={(event) => setDraft(event.target.value)} placeholder="Ask about this invoice" /> <Button type="submit" disabled={streaming || !draft.trim()}> Send </Button> </form> </div> );}Usage#
Message scroller is the scroll container for a conversation. It opens at the newest message, starts each new turn near the top so the reply has room to stream in, holds the reader's place when older messages load above, and shows a button back to the end once they've scrolled away. It wraps the headless @shadcn/react message scroller and isn't used in the product yet. The mistake it replaces is an overflow-y-auto div with a scroll-to-bottom effect, which drags the reader down every time a token arrives.
When to use
- For any transcript that grows: a copilot thread, a supplier conversation, an agent's call log.
- When replies stream in and the view should follow only while the reader is already at the end.
- When older history loads above and the reader's place has to hold still.
- When something needs to jump to a message, such as a search hit or a citation, through
useMessageScroller.
When not to use
- For a scrolling panel that isn't a conversation. Use Scroll area
- For the whole assistant thread with its composer and message parts. It uses a scroller inside. Use Thread
- For a call transcript with speakers and timecodes. Use Live transcript
- For a record's history of events in time order. Use Timeline
The Scroll Edge Rule
The One Filled Button Rule
Anatomy#
- Viewport. The scrolling region: a focusable
role="region"labelled Messages by default, with a thin scrollbar, a stable gutter and a bottom fade. - Item. One row: a message, a date marker or a load-more row.
messageIdmakes it a jump target;scrollAnchormarks a turn boundary. - Scroll button. A 28px secondary icon button centered 16px from the bottom (or top for
direction="start"). Slides in when there's content to scroll toward. - Content.
role="log"witharia-relevant="additions", a column with 24px between rows, at least the viewport's height. - Root. The frame: a relative flex column that fills its parent and clips. It mirrors the viewport's scroll attributes.
- Provider.
MessageScrollerProviderrenders nothing. It owns the scroll state, the behavior props and the hooks' context.
Examples#
Jump to the start or the end
It opens at the newest message. Scroll up and the end button slides in; scroll down from the top and the start button appears. Each is inert when there's nothing to scroll toward.
import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from "@oration/canon/components/message-scroller";export function JumpToEdges() { const thread = [ "Hi, this is Jordan from Cedarline AP. Halcyon's Sep 18 payment came back as returned.", "We changed banks on Sep 1. Did you get the letter?", "We didn't. Can you send it to ap@cedarline.com?", "Sent just now, with the new routing number.", "Got it. We verify bank changes by phone before updating anything.", "Of course. Call the number on our website and ask for Wen Zhou.", "Spoke with Wen. The change is verified.", "Thanks. Will the returned payment be resent?", "Yes, in Friday's run. That covers INV-20431 and INV-20435.", "Perfect. Can you send remittance advice to billing@halcyon.co?", "Done. You'll get it when the run settles on Oct 2.", "Great, thanks for sorting this out so fast.", ].map((text, index) => ({ id: `halcyon-${index}`, from: index % 2 === 0 ? "Jordan Lee" : "Halcyon AR", text, })); return ( <div className="h-80 w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border"> <MessageScrollerProvider> <MessageScroller> <MessageScrollerViewport aria-label="Conversation with Halcyon"> <MessageScrollerContent className="gap-3 p-4"> {thread.map((message) => ( <MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor={message.from === "Jordan Lee"} > <p className="text-xs font-medium text-muted-foreground"> {message.from} </p> <p className="text-sm text-pretty"> {message.text} </p> </MessageScrollerItem> ))} </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton direction="start" /> <MessageScrollerButton /> </MessageScroller> </MessageScrollerProvider> </div> );}Loading earlier messages
Older rows are prepended above the first one on screen, and preserveScrollOnPrepend keeps what you were reading in place.
import { Button } from "@oration/canon/components/button";import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from "@oration/canon/components/message-scroller";import * as React from "react";export function LoadOlder() { const pages = [ [ "Priya Raman: Can we move Orchard Street to net 45?", "Maya Okafor: Only if they agree in writing.", ], [ "Priya Raman: Orchard Street sent their new W-9.", "Maya Okafor: Thanks, I'll file it.", ], [ "Priya Raman: Orchard Street's August invoices are all approved.", "Maya Okafor: Great, they'll go in the next run.", ], ]; const [loaded, setLoaded] = React.useState(1); const [loading, setLoading] = React.useState(false); const visible = pages.slice(pages.length - loaded).flat(); const done = loaded === pages.length; return ( <div className="h-64 w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border"> <MessageScrollerProvider> <MessageScroller> <MessageScrollerViewport aria-label="Orchard Street thread"> <MessageScrollerContent className="gap-3 p-4"> <MessageScrollerItem messageId="orchard-start"> {done ? ( <p className="text-center text-xs text-muted-foreground"> Start of conversation </p> ) : ( <div className="flex justify-center"> <Button type="button" variant="ghost" size="sm" disabled={loading} onClick={() => { setLoading(true); window.setTimeout(() => { setLoaded((n) => n + 1); setLoading(false); }, 600); }} > {loading ? "Loading" : "Load earlier messages"} </Button> </div> )} </MessageScrollerItem> {visible.map((line) => ( <MessageScrollerItem key={line} messageId={line} > <p className="text-sm text-pretty"> {line} </p> </MessageScrollerItem> ))} </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton /> </MessageScroller> </MessageScrollerProvider> </div> );}Opening a saved conversation at its last turn
With defaultScrollPosition="last-anchor", a long last answer opens at the question that started it, not halfway through the reply.
import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from "@oration/canon/components/message-scroller";export function OpenAtLastTurn() { return ( <div className="h-72 w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border"> <MessageScrollerProvider defaultScrollPosition="last-anchor"> <MessageScroller> <MessageScrollerViewport aria-label="Saved conversation"> <MessageScrollerContent className="p-4"> <MessageScrollerItem messageId="q1" scrollAnchor> <p className="ml-auto w-fit max-w-[80%] rounded-xl bg-muted/70 px-3 py-2 text-sm"> How many invoices are in Friday's run? </p> </MessageScrollerItem> <MessageScrollerItem messageId="a1"> <p className="text-sm"> 212 invoices to 48 suppliers. </p> </MessageScrollerItem> <MessageScrollerItem messageId="q2" scrollAnchor> <p className="ml-auto w-fit max-w-[80%] rounded-xl bg-muted/70 px-3 py-2 text-sm"> Summarize what's unusual about it. </p> </MessageScrollerItem> <MessageScrollerItem messageId="a2"> <div className="flex flex-col gap-2 text-sm text-pretty"> <p> Three things stand out in the Friday, Oct 2 run. </p> <p> Northwind Freight is 38% higher than its usual week, mostly from two invoices for the Memphis lane. </p> <p> Halcyon's returned Sep 18 payment is being resent, so Halcyon appears twice. </p> <p> Orchard Street moved to net 45, so four of its invoices that would have been in this run are now in the Oct 16 run. </p> <p> Everything else is within 5% of a normal week. Want me to hold the Northwind invoices for review? </p> </div> </MessageScrollerItem> </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton /> </MessageScroller> </MessageScrollerProvider> </div> );}States#
| State | Treatment |
|---|---|
| Pending scroll | The viewport is invisible until its opening position is applied, so it never flashes the oldest messages first. |
| At the end | The end button is inactive: 95% scale, shifted down, transparent and out of the tab order. |
| Scrolled away | With content below, the button scales and fades in over 200ms. data-scrollable lists start, end or both. |
| Following | With autoScroll, the view stays at the live edge as content grows, until the reader scrolls. |
| Autoscrolling | While the scroller is moving itself, data-autoscrolling is set and the scrollbar hides. |
Behavior#
defaultScrollPositionsets where it opens, once, on the first non-empty render:"end"(default),"start", or"last-anchor", which opens at the last anchored row and falls back to the end when the turn fits.autoScrollis off by default. On, it follows new content only while the reader is withinscrollEdgeThreshold(8px) of the end; a wheel, touch or keyboard scroll releases it.- A newly appended
scrollAnchorrow, usually the person's own message, is scrolled near the top withscrollPreviousItemPeek(64px) of the row before it showing, and a spacer below makes room for the reply. preserveScrollOnPrepend(on by default) keeps the first visible message where it is when older rows are added above.useMessageScroller()returnsscrollToEnd,scrollToStartandscrollToMessage(id, { align, behavior, scrollMargin }).useMessageScrollerScrollable()returns{ start, end }, anduseMessageScrollerVisibility()the current anchor and the visible message ids. All three must be called under the provider.- The scroll button scrolls smoothly toward its edge. It is inert and leaves the tab order when there is nothing to scroll toward.
- Items use
content-visibility: autowith a 10rem placeholder size, so a long transcript only lays out the rows on screen. - The root fills its parent. Give the parent a height, such as
flex-1 min-h-0in a column or a fixed height.
Do and don't#
scrollAnchor, so a new question starts near the top and the answer streams in below it.autoScroll follows only while they're at the end.Content#
- Name the viewport after the conversation: Conversation about INV-20417, not the default Messages.
- Date and system rows are short and in sentence case: Today, Priya Raman joined.
- A load-more row says what it loads: Load earlier messages. When there's nothing left: Start of conversation.
Accessibility#
- The viewport is a labelled region with
tabIndex={0}, so keyboard users can focus it and scroll with the usual keys. Give it a specificaria-label. - Content is a
role="log"that announces added rows. Setaria-busyon it while a reply streams, so it isn't read out word by word. - The scroll button is icon-only with a visually hidden Scroll to end (or Scroll to start). When inactive it is inert and out of the tab order.
- Scrolling the transcript never moves focus.
| Keys | Action |
|---|---|
| Tab | Focuses the viewport, then the scroll button when it shows. |
| ↑↓ | Scrolls the focused viewport a line. |
| Page UpPage Down | Scrolls a page. Space also pages down. |
| HomeEnd | Jumps to the start or the end. |
| Enter | On the scroll button, jumps to its edge. |
Design tokens#
| Token | Used for |
|---|---|
--background | Scroll button fill |
--border | Scroll button edge |
--muted | Scroll button hover |
scroll-fade-b | Bottom edge fade on the viewport |
scrollbar-thin | Thin scrollbar with a stable gutter |
gap-6 | 24px between rows |
API reference#
MessageScrollerProvider
Owns scroll state and behavior. Renders no DOM.
| Prop | Type | Default | Description |
|---|---|---|---|
autoScroll | boolean | false | Follow new content while the reader is at the end. |
defaultScrollPosition | "start" | "end" | "last-anchor" | "end" | Where it opens, applied once. |
scrollEdgeThreshold | number | 8 | Pixels from an edge that still count as at it. |
scrollMargin | number | 0 | Margin on the aligned edge for jumps and visibility. |
scrollPreviousItemPeek | number | 64 | How much of the previous row stays visible above a new anchor. |
children | React.ReactNode | No default | The scroller and anything that calls the hooks. |
MessageScroller
The frame. Fills its parent.
Other props spread onto @shadcn/react MessageScroller.Root (<div>).
No props of its own.
MessageScrollerViewport
The scrolling region.
Other props spread onto @shadcn/react MessageScroller.Viewport (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
preserveScrollOnPrepend | boolean | true | Hold the first visible row when rows are added above. |
aria-label | string | "Messages" | Names the region. |
role | string | "region" | Landmark role. |
tabIndex | number | 0 | Keeps it keyboard-scrollable. |
MessageScrollerContent
The transcript. Every direct child should be an item.
Other props spread onto @shadcn/react MessageScroller.Content (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
role | string | "log" | Live-region role. |
aria-relevant | string | "additions" | What gets announced. |
aria-busy | boolean | No default | Set while a reply streams. |
spacerClassName | string | No default | Classes for the spacer that makes room under anchors. |
MessageScrollerItem
One row.
Other props spread onto @shadcn/react MessageScroller.Item (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
messageId | string | No default | Stable id for scrollToMessage, visibility and prepend holding. |
scrollAnchor | boolean | false | Marks a turn boundary that new turns anchor to. |
MessageScrollerButton
Scrolls to an edge. Renders a Canon Button unless render is given.
Other props spread onto @shadcn/react MessageScroller.Button (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
direction | "start" | "end" | "end" | Which edge it scrolls toward, and where it sits. |
behavior | ScrollBehavior | "smooth" | Native scroll behavior. |
variant | Button variant | "secondary" | Button style. |
size | Button size | "icon-sm" | Button size. |
children | React.ReactNode | No default | Replaces the arrow and its hidden label. |
render | React.ReactElement | render function | No default | Custom element to render. |
useMessageScroller
Imperative controls. Each returns false when it couldn't apply.
| Prop | Type | Default | Description |
|---|---|---|---|
scrollToEnd | (options?: { align?, behavior?, scrollMargin? }) => boolean | No default | Scrolls to the newest row. |
scrollToStart | (options?) => boolean | No default | Scrolls to the top. |
scrollToMessage | (messageId: string, options?) => boolean | No default | Scrolls to a mounted row, aligned to start by default. |
useMessageScrollerScrollable / useMessageScrollerVisibility
Scroll state for sibling UI.
| Prop | Type | Default | Description |
|---|---|---|---|
useMessageScrollerScrollable() | { start: boolean; end: boolean } | No default | Whether there's content above or below. |
useMessageScrollerVisibility() | { currentAnchorId: string | null; visibleMessageIds: string[] } | No default | The current turn and the rows on screen. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Not used anywhere in the product yet, and marked experimental. Thread and the transcripts don't use it.
The floating scroll button draws its edge with a CSS border and has no lift. The Hairline-and-Lift Rule asks a raised control for one composite shadow.
The button leaves over 400ms and enters over 200ms, so it leaves slower than it comes, and neither transition is behind motion-safe.
The scroll button is icon-only without a tooltip, and its hidden label is fixed English unless you pass children.