Skip to content

Message scroller

A conversation viewport that sticks to the newest message and offers a jump back down.

Category
Layout
Adoption
Not used yet
import { MessageScroller } from "@oration/canon/components/message-scroller";
packages/canon/src/components/message-scroller.tsx

Why is INV-20417 from Northwind Freight on hold?

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.

Who usually signs off on freight overages?

Tomás Ferreira approved the last three freight overages under $2,000. This one is $912, so it's within his limit.

import { 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 viewport fades its bottom edge, so a transcript clipped below shows that there's more.

The One Filled Button Rule

The scroll button is secondary. The composer's Send is the filled button beside a conversation.

Anatomy#

When does the Friday run close?

At 2:00 PM CT. Invoices approved after that go in the Oct 9 run.

Is Halcyon's W-9 on file?

Yes, received Sep 14. It's valid through 2026.

Thanks

  1. Viewport. The scrolling region: a focusable role="region" labelled Messages by default, with a thin scrollbar, a stable gutter and a bottom fade.
  2. Item. One row: a message, a date marker or a load-more row. messageId makes it a jump target; scrollAnchor marks a turn boundary.
  3. 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.
  4. Content. role="log" with aria-relevant="additions", a column with 24px between rows, at least the viewport's height.
  5. Root. The frame: a relative flex column that fills its parent and clips. It mirrors the viewport's scroll attributes.
  6. Provider. MessageScrollerProvider renders 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.

Jordan Lee

Hi, this is Jordan from Cedarline AP. Halcyon's Sep 18 payment came back as returned.

Halcyon AR

We changed banks on Sep 1. Did you get the letter?

Jordan Lee

We didn't. Can you send it to ap@cedarline.com?

Halcyon AR

Sent just now, with the new routing number.

Jordan Lee

Got it. We verify bank changes by phone before updating anything.

Halcyon AR

Of course. Call the number on our website and ask for Wen Zhou.

Jordan Lee

Spoke with Wen. The change is verified.

Halcyon AR

Thanks. Will the returned payment be resent?

Jordan Lee

Yes, in Friday's run. That covers INV-20431 and INV-20435.

Halcyon AR

Perfect. Can you send remittance advice to billing@halcyon.co?

Jordan Lee

Done. You'll get it when the run settles on Oct 2.

Halcyon AR

Great, thanks for sorting this out so fast.

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.

Priya Raman: Orchard Street's August invoices are all approved.

Maya Okafor: Great, they'll go in the next run.

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.

How many invoices are in Friday's run?

212 invoices to 48 suppliers.

Summarize what's unusual about it.

Three things stand out in the Friday, Oct 2 run.

Northwind Freight is 38% higher than its usual week, mostly from two invoices for the Memphis lane.

Halcyon's returned Sep 18 payment is being resent, so Halcyon appears twice.

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.

Everything else is within 5% of a normal week. Want me to hold the Northwind invoices for review?

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#

States
StateTreatment
Pending scrollThe viewport is invisible until its opening position is applied, so it never flashes the oldest messages first.
At the endThe end button is inactive: 95% scale, shifted down, transparent and out of the tab order.
Scrolled awayWith content below, the button scales and fades in over 200ms. data-scrollable lists start, end or both.
FollowingWith autoScroll, the view stays at the live edge as content grows, until the reader scrolls.
AutoscrollingWhile the scroller is moving itself, data-autoscrolling is set and the scrollbar hides.

Behavior#

  • defaultScrollPosition sets 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.
  • autoScroll is off by default. On, it follows new content only while the reader is within scrollEdgeThreshold (8px) of the end; a wheel, touch or keyboard scroll releases it.
  • A newly appended scrollAnchor row, usually the person's own message, is scrolled near the top with scrollPreviousItemPeek (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() returns scrollToEnd, scrollToStart and scrollToMessage(id, { align, behavior, scrollMargin }). useMessageScrollerScrollable() returns { start, end }, and useMessageScrollerVisibility() 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: auto with 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-0 in a column or a fixed height.

Do and don't#

Do. Mark the person's messages as scrollAnchor, so a new question starts near the top and the answer streams in below it.
Don't. Scroll to the very bottom on every token. The question scrolls out of view before the answer is done.
Do. Let people scroll up to reread while a reply streams. autoScroll follows only while they're at the end.
Don't. Force the view back to the end while someone is reading further up.
Do. Give the scroller a bounded height from its layout.
Don't. Put it in an auto-height container. It grows with the transcript and never scrolls.

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 specific aria-label.
  • Content is a role="log" that announces added rows. Set aria-busy on 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.
Keyboard interactions
KeysAction
TabFocuses the viewport, then the scroll button when it shows.
↑↓Scrolls the focused viewport a line.
Page UpPage DownScrolls a page. Space also pages down.
HomeEndJumps to the start or the end.
EnterOn the scroll button, jumps to its edge.

Design tokens#

Design tokens
TokenUsed for
--backgroundScroll button fill
--borderScroll button edge
--mutedScroll button hover
scroll-fade-bBottom edge fade on the viewport
scrollbar-thinThin scrollbar with a stable gutter
gap-624px between rows

API reference#

MessageScrollerProvider

Owns scroll state and behavior. Renders no DOM.

Props of MessageScrollerProvider
PropTypeDefaultDescription
autoScrollbooleanfalseFollow new content while the reader is at the end.
defaultScrollPosition"start" | "end" | "last-anchor""end"Where it opens, applied once.
scrollEdgeThresholdnumber8Pixels from an edge that still count as at it.
scrollMarginnumber0Margin on the aligned edge for jumps and visibility.
scrollPreviousItemPeeknumber64How much of the previous row stays visible above a new anchor.
childrenReact.ReactNodeNo defaultThe 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>).

Props of MessageScrollerViewport
PropTypeDefaultDescription
preserveScrollOnPrependbooleantrueHold the first visible row when rows are added above.
aria-labelstring"Messages"Names the region.
rolestring"region"Landmark role.
tabIndexnumber0Keeps it keyboard-scrollable.

MessageScrollerContent

The transcript. Every direct child should be an item.

Other props spread onto @shadcn/react MessageScroller.Content (<div>).

Props of MessageScrollerContent
PropTypeDefaultDescription
rolestring"log"Live-region role.
aria-relevantstring"additions"What gets announced.
aria-busybooleanNo defaultSet while a reply streams.
spacerClassNamestringNo defaultClasses for the spacer that makes room under anchors.

MessageScrollerItem

One row.

Other props spread onto @shadcn/react MessageScroller.Item (<div>).

Props of MessageScrollerItem
PropTypeDefaultDescription
messageIdstringNo defaultStable id for scrollToMessage, visibility and prepend holding.
scrollAnchorbooleanfalseMarks 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>).

Props of MessageScrollerButton
PropTypeDefaultDescription
direction"start" | "end""end"Which edge it scrolls toward, and where it sits.
behaviorScrollBehavior"smooth"Native scroll behavior.
variantButton variant"secondary"Button style.
sizeButton size"icon-sm"Button size.
childrenReact.ReactNodeNo defaultReplaces the arrow and its hidden label.
renderReact.ReactElement | render functionNo defaultCustom element to render.

useMessageScroller

Imperative controls. Each returns false when it couldn't apply.

Props of useMessageScroller
PropTypeDefaultDescription
scrollToEnd(options?: { align?, behavior?, scrollMargin? }) => booleanNo defaultScrolls to the newest row.
scrollToStart(options?) => booleanNo defaultScrolls to the top.
scrollToMessage(messageId: string, options?) => booleanNo defaultScrolls to a mounted row, aligned to start by default.

useMessageScrollerScrollable / useMessageScrollerVisibility

Scroll state for sibling UI.

Props of useMessageScrollerScrollable / useMessageScrollerVisibility
PropTypeDefaultDescription
useMessageScrollerScrollable(){ start: boolean; end: boolean }No defaultWhether there's content above or below.
useMessageScrollerVisibility(){ currentAnchorId: string | null; visibleMessageIds: string[] }No defaultThe 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.