Westy Design Systemv0.2.7
OverviewComponentsExamplesStorybook

Foundations

  • Typography
  • Colors
  • Spacing
  • Radius
  • Shadows
  • Icons

Components

  • Accordion
  • Alert
  • Alert Dialog
  • Aspect Ratio
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Data Table
  • Date Picker
  • Dialog
  • Direction
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Label
  • Menubar
  • Native Select
  • Navigation Menu
  • Pagination
  • Popover
  • Progress
  • Questionnaire
  • Radio Group
  • Resizable
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Sonner
  • Spinner
  • Switch
  • Table
  • Tabs
  • Textarea
  • Toggle
  • Toggle Group
  • Tooltip

Chat

  • Attachment
  • Bubble
  • Marker
  • Message
  • Message Scroller

Chat

Message Scroller

A chat scroll container that anchors new turns near the top, follows streaming replies, and keeps the reader where they were when history loads above them.

Playground

We are rewriting the checkout. Where should I start?
WD
Start with the cart state. Everything downstream — totals, taxes, shipping — reads from it, so a shaky cart makes every later step shaky too.
It is a reducer today. Keep it?
WD
Keep the reducer. Move the pricing out of it: a reducer that also prices is a reducer you cannot test without a price list.
What about the address form?
WD
Treat it as a separate step with its own validation. Countries disagree about what a postal code is, so validate per country and never on blur alone.
And payment?
WD
Payment is the one step you do not own. Render the provider element, keep your own state out of it, and treat the confirmation webhook as the source of truth.
How do I test the whole flow?
WD
One end-to-end pass per country you actually ship to, and unit tests for the pricing module. The rest is cheaper to catch in types than in a browser.
—autoScroll
—defaultScrollPosition
—scrollPreviousItemPeek
—preserveScrollOnPrepend
—direction
—dir
Usage
The whole component exists to honour one rule: never move the reader against their intent. It must sit inside a height-constrained parent, because MessageScroller is size-full and has nothing of its own to size against.
We are rewriting the checkout. Where should I start?
WD
Start with the cart state. Everything downstream — totals, taxes, shipping — reads from it, so a shaky cart makes every later step shaky too.
It is a reducer today. Keep it?
WD
Keep the reducer. Move the pricing out of it: a reducer that also prices is a reducer you cannot test without a price list.
What about the address form?
WD
Treat it as a separate step with its own validation. Countries disagree about what a postal code is, so validate per country and never on blur alone.
And payment?
WD
Payment is the one step you do not own. Render the provider element, keep your own state out of it, and treat the confirmation webhook as the source of truth.
How do I test the whole flow?
WD
One end-to-end pass per country you actually ship to, and unit tests for the pricing module. The rest is cheaper to catch in types than in a browser.
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@workspace/ui/components/message-scroller"

<div className="h-96">
  <MessageScrollerProvider>
    <MessageScroller>
      <MessageScrollerViewport>
        <MessageScrollerContent>
          {messages.map((message) => (
            <MessageScrollerItem
              key={message.id}
              messageId={message.id}
              scrollAnchor={message.role === "user"}
            >
              <Message>{/* … */}</Message>
            </MessageScrollerItem>
          ))}
        </MessageScrollerContent>
      </MessageScrollerViewport>
      <MessageScrollerButton />
    </MessageScroller>
  </MessageScrollerProvider>
</div>
Composition
MessageScrollerProvider is headless — it owns the scroll state and renders nothing, so the hooks work anywhere below it, including in a toolbar that sits outside the frame. Everything else is a real element: MessageScroller is the frame, MessageScrollerViewport is the element that actually scrolls, and every direct child of MessageScrollerContent must be a MessageScrollerItem so it can be measured and anchored.
MessageScrollerProvider
└── MessageScroller
    ├── MessageScrollerViewport
    │   └── MessageScrollerContent
    │       ├── MessageScrollerItem
    │       └── MessageScrollerItem
    └── MessageScrollerButton
Turn anchoring
scrollAnchor marks the row that should settle near the top when a new turn begins — usually the user message, but any row that starts a meaningful exchange works. scrollPreviousItemPeek (64px by default) leaves a sliver of the previous turn visible above it, so the new turn arrives with context instead of on a blank screen.
scrollPreviousItemPeek={64}
We are rewriting the checkout. Where should I start?
WD
Start with the cart state. Everything downstream — totals, taxes, shipping — reads from it, so a shaky cart makes every later step shaky too.
It is a reducer today. Keep it?
WD
Keep the reducer. Move the pricing out of it: a reducer that also prices is a reducer you cannot test without a price list.
What about the address form?
WD
Treat it as a separate step with its own validation. Countries disagree about what a postal code is, so validate per country and never on blur alone.
And payment?
WD
Payment is the one step you do not own. Render the provider element, keep your own state out of it, and treat the confirmation webhook as the source of truth.
How do I test the whole flow?
WD
One end-to-end pass per country you actually ship to, and unit tests for the pricing module. The rest is cheaper to catch in types than in a browser.
<MessageScrollerProvider scrollPreviousItemPeek={64}>
  <MessageScroller>
    <MessageScrollerViewport>
      <MessageScrollerContent>
        {messages.map((message) => (
          <MessageScrollerItem
            key={message.id}
            messageId={message.id}
            scrollAnchor={message.role === "user"}
          >
            <Message>{/* … */}</Message>
          </MessageScrollerItem>
        ))}
      </MessageScrollerContent>
    </MessageScrollerViewport>
  </MessageScroller>
</MessageScrollerProvider>
Auto scroll
autoScroll follows content arriving at the live edge, but only while the reader is already there. Scrolling away, selecting text, or pressing a navigation key disengages it; the button re-engages it. Pass aria-busy to the content while a reply streams so screen readers batch the announcement instead of reading every token.
idle
We are rewriting the checkout. Where should I start?
WD
Start with the cart state. Everything downstream — totals, taxes, shipping — reads from it, so a shaky cart makes every later step shaky too.
It is a reducer today. Keep it?
WD
Keep the reducer. Move the pricing out of it: a reducer that also prices is a reducer you cannot test without a price list.
What about the address form?
WD
Treat it as a separate step with its own validation. Countries disagree about what a postal code is, so validate per country and never on blur alone.
<MessageScrollerProvider autoScroll>
  <MessageScroller>
    <MessageScrollerViewport>
      <MessageScrollerContent aria-busy={isStreaming}>
        {/* transcript */}
      </MessageScrollerContent>
    </MessageScrollerViewport>
    <MessageScrollerButton />
  </MessageScroller>
</MessageScrollerProvider>
Opening position
defaultScrollPosition decides where a transcript opens, and it applies once per mount — changing it later does nothing until the provider remounts, which is why the example below is keyed. While the position is being applied the viewport carries data-pending-scroll, so you can hide it and avoid a flash of the wrong position.
remounts on change
We are rewriting the checkout. Where should I start?
WD
Start with the cart state. Everything downstream — totals, taxes, shipping — reads from it, so a shaky cart makes every later step shaky too.
It is a reducer today. Keep it?
WD
Keep the reducer. Move the pricing out of it: a reducer that also prices is a reducer you cannot test without a price list.
What about the address form?
WD
Treat it as a separate step with its own validation. Countries disagree about what a postal code is, so validate per country and never on blur alone.
And payment?
WD
Payment is the one step you do not own. Render the provider element, keep your own state out of it, and treat the confirmation webhook as the source of truth.
How do I test the whole flow?
WD
One end-to-end pass per country you actually ship to, and unit tests for the pricing module. The rest is cheaper to catch in types than in a browser.
<MessageScrollerProvider defaultScrollPosition="last-anchor">
  {/* transcript */}
</MessageScrollerProvider>
Which opening position to use
ValueDescription
"end"The default. Opens at the absolute bottom — right for a live thread the reader is returning to mid-conversation.
"last-anchor"Opens at the last anchored turn instead of the last pixel, so a reopened thread starts at the beginning of the final exchange rather than at the end of a long reply.
"start"Opens at the top. Use it when the transcript is the document — a shared conversation, an archived thread, a transcript being reviewed.
Preserving position on prepend
When older messages load above the current view, the reader would normally be thrown down the page by the height that just appeared. preserveScrollOnPrepend — on the viewport, and on by default — pins them to the row they were reading instead. It relies on stable messageId values: regenerate the ids on every fetch and there is nothing left to pin to. Turn the switch off to see the jump it prevents.
We are rewriting the checkout. Where should I start?
WD
Start with the cart state. Everything downstream — totals, taxes, shipping — reads from it, so a shaky cart makes every later step shaky too.
It is a reducer today. Keep it?
WD
Keep the reducer. Move the pricing out of it: a reducer that also prices is a reducer you cannot test without a price list.
What about the address form?
WD
Treat it as a separate step with its own validation. Countries disagree about what a postal code is, so validate per country and never on blur alone.
And payment?
WD
Payment is the one step you do not own. Render the provider element, keep your own state out of it, and treat the confirmation webhook as the source of truth.
How do I test the whole flow?
WD
One end-to-end pass per country you actually ship to, and unit tests for the pricing module. The rest is cheaper to catch in types than in a browser.
<MessageScrollerViewport preserveScrollOnPrepend>
  <MessageScrollerContent>
    {[...olderMessages, ...messages].map((message) => (
      <MessageScrollerItem key={message.id} messageId={message.id}>
        <Message>{/* … */}</Message>
      </MessageScrollerItem>
    ))}
  </MessageScrollerContent>
</MessageScrollerViewport>
Jumping to a message
useMessageScroller drives the transcript from anywhere inside the provider — a search result, a citation, a table of contents. scrollToMessage returns true when the scroll ran or was queued for a row that has not mounted yet, and false when the target does not exist at all.
We are rewriting the checkout. Where should I start?
WD
Start with the cart state. Everything downstream — totals, taxes, shipping — reads from it, so a shaky cart makes every later step shaky too.
It is a reducer today. Keep it?
WD
Keep the reducer. Move the pricing out of it: a reducer that also prices is a reducer you cannot test without a price list.
What about the address form?
WD
Treat it as a separate step with its own validation. Countries disagree about what a postal code is, so validate per country and never on blur alone.
And payment?
WD
Payment is the one step you do not own. Render the provider element, keep your own state out of it, and treat the confirmation webhook as the source of truth.
How do I test the whole flow?
WD
One end-to-end pass per country you actually ship to, and unit tests for the pricing module. The rest is cheaper to catch in types than in a browser.
const { scrollToMessage, scrollToStart, scrollToEnd } = useMessageScroller()

scrollToMessage(messageId, {
  align: "start",
  behavior: "smooth",
  scrollMargin: 16,
})

scrollToStart({ behavior: "smooth" })
scrollToEnd({ behavior: "smooth" })
Tracking the reader
useMessageScrollerVisibility reports the anchored turn and the ids currently on screen, in document order — enough to highlight the active turn in a sidebar or to mark a thread as read. The observer only runs while something subscribes, so the hook costs nothing on pages that do not call it.
We are rewriting the checkout. Where should I start?
WD
Start with the cart state. Everything downstream — totals, taxes, shipping — reads from it, so a shaky cart makes every later step shaky too.
It is a reducer today. Keep it?
WD
Keep the reducer. Move the pricing out of it: a reducer that also prices is a reducer you cannot test without a price list.
What about the address form?
WD
Treat it as a separate step with its own validation. Countries disagree about what a postal code is, so validate per country and never on blur alone.
And payment?
WD
Payment is the one step you do not own. Render the provider element, keep your own state out of it, and treat the confirmation webhook as the source of truth.
How do I test the whole flow?
WD
One end-to-end pass per country you actually ship to, and unit tests for the pricing module. The rest is cheaper to catch in types than in a browser.
currentAnchorIdnullvisibleMessageIds[]
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
Reading scroll state
useMessageScrollerScrollable reports whether there is more transcript in each direction. The same state mirrors to data-scrollable on the root and the viewport, and it is what makes MessageScrollerButton fade out and go inert at an edge. Shorten the transcript below and both values fall to false.
Today
We are rewriting the checkout. Where should I start?
WD
Start with the cart state. Everything downstream — totals, taxes, shipping — reads from it, so a shaky cart makes every later step shaky too.
It is a reducer today. Keep it?
WD
Keep the reducer. Move the pricing out of it: a reducer that also prices is a reducer you cannot test without a price list.
What about the address form?
WD
Treat it as a separate step with its own validation. Countries disagree about what a postal code is, so validate per country and never on blur alone.
And payment?
WD
Payment is the one step you do not own. Render the provider element, keep your own state out of it, and treat the confirmation webhook as the source of truth.
How do I test the whole flow?
WD
One end-to-end pass per country you actually ship to, and unit tests for the pricing module. The rest is cheaper to catch in types than in a browser.
scrollablestart: falseend: falseThe whole transcript fits.
const { start, end } = useMessageScrollerScrollable()
Rows other than messages
MessageScrollerItem is a row boundary, not a message. Date separators, unread markers, typing indicators, and system notices all go in one — that is what keeps them measurable and stops them from breaking anchoring. Leave scrollAnchor off for rows that do not start a turn.
<MessageScrollerItem messageId="marker-today">
  <Marker variant="separator">
    <MarkerContent>Today</MarkerContent>
  </Marker>
</MessageScrollerItem>

<MessageScrollerItem messageId={message.id} scrollAnchor>
  <Message align="end">{/* … */}</Message>
</MessageScrollerItem>
Animation
Animate rows with transform and opacity only. Animating height, margin, or padding changes the scroll height on every frame, which fights the anchoring maths and makes the transcript twitch. Skip the entrance entirely for prefers-reduced-motion.
const MotionMessageScrollerItem = motion.create(MessageScrollerItem)

<MotionMessageScrollerItem
  messageId={message.id}
  scrollAnchor={message.role === "user"}
  initial={{ opacity: 0, y: 16, scale: 0.95 }}
  animate={{ opacity: 1, y: 0, scale: 1 }}
  transition={{ duration: 0.2, ease: "easeOut" }}
>
  <Message>{/* … */}</Message>
</MotionMessageScrollerItem>
Performance
Scrolling is tracked imperatively, so a scroll never rerenders React. Rows are real DOM nodes with content-visibility: auto and contain-intrinsic-size, which keeps hundreds to low thousands of turns comfortable. There is no built-in virtualisation; past that size, use MessageScrollerViewport as the scroll element for @tanstack/react-virtual and let the virtualiser own the rows.
Data attributes
The component publishes its state to the DOM, so you can style against it without subscribing to a hook.
AttributeDescription
data-pending-scrollOn the viewport while the opening position is being applied. Hide the content against it to avoid a flash.
data-autoscrollingOn the root and the viewport during a programmatic scroll. The viewport uses it to hide the scrollbar mid-jump.
data-scrollableOn the root and the viewport, with the values start and end for the directions that still have transcript left.
data-activeOn the button. At false it fades out, stops taking pointer events, and is removed from the tab order.
data-scroll-anchorOn every item, mirroring scrollAnchor.
Accessibility
The viewport is a focusable role="region" labelled "Messages", so a keyboard can reach the transcript and page through it — override aria-label when the page holds more than one thread. The content is a role="log" with aria-relevant="additions", so new rows are announced and edits to old ones are not. The button is a real button that goes inert at the edge rather than staying focusable and doing nothing.
<MessageScrollerViewport aria-label="Support thread">
  <MessageScrollerContent aria-busy={isStreaming}>
    {/* transcript */}
  </MessageScrollerContent>
</MessageScrollerViewport>
API Reference
Six parts and three hooks. Every part but the provider renders a div — except the button, which renders a Button through render — and passes its native props through.
PartPropDefault
MessageScrollerProviderautoScroll?: booleanfalse
MessageScrollerProviderdefaultScrollPosition?: "start" | "end" | "last-anchor""end"
MessageScrollerProviderscrollPreviousItemPeek?: number64
MessageScrollerProviderscrollEdgeThreshold?: number8
MessageScrollerProviderscrollMargin?: number0
MessageScrollerclassName?: string—
MessageScrollerViewportpreserveScrollOnPrepend?: booleantrue
MessageScrollerViewportaria-label?: string"Messages"
MessageScrollerContentspacerClassName?: string—
MessageScrollerItemmessageId?: string—
MessageScrollerItemscrollAnchor?: booleanfalse
MessageScrollerButtondirection?: "start" | "end""end"
MessageScrollerButtonbehavior?: ScrollBehavior"smooth"
MessageScrollerButtonvariant, size (forwarded to Button)"secondary", "icon-sm"
Hooks
HookReturns
useMessageScroller{ scrollToMessage, scrollToStart, scrollToEnd } — each returns a boolean
useMessageScrollerVisibility{ currentAnchorId: string | null, visibleMessageIds: string[] }
useMessageScrollerScrollable{ start: boolean, end: boolean }

Tokens used

7 design tokens consumed by the Message Scroller component.

  • --background

    Background

    bg-background
  • --foreground

    Foreground

    text-foregroundhover:text-foreground
  • --muted

    Muted

    hover:bg-muted
  • --border

    Border

    border-border
  • --ring

    Ring

    focus-visible:ring-ring/50
  • --radius-md

    Radius Medium

    rounded-md
  • --spacing

    Spacing unit (4px)

    gap-8bottom-4top-4inset-s-1/2size-8[contain-intrinsic-size:auto_10rem]