Skip to content
Component installs need the registry setup
VegaStack Design

Message Scroller

A chat transcript scroller that follows the live edge only while the reader is, anchors each new turn near the top, and never pulls them away.

Status
stable
Since
0.1.0
Accessibility pattern
focusable scrollable region

Last updated

Hey — welcome to the thread!
This viewport auto-scrolls to the latest message.
Scroll up and the jump-to-latest button appears.
Older messages keep their position when more load.
It virtualises long histories with content-visibility.
Try the controls below.

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Message Scroller before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify message-scroller

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/message-scroller

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/button, @vegastack/use-media-query.

It also adds the sanctioned engine to your package.json: @shadcn/react.

Usage

import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller";

<MessageScrollerProvider autoScroll defaultScrollPosition="end">
  <MessageScroller>
    <MessageScrollerViewport aria-label="Conversation">
      <MessageScrollerContent>
        {messages.map((message) => (
          <MessageScrollerItem
            key={message.id}
            messageId={message.id}
            scrollAnchor={message.role === "user"}
          >
            {/* a Message row */}
          </MessageScrollerItem>
        ))}
      </MessageScrollerContent>
    </MessageScrollerViewport>
    {/* The button is a SIBLING of the viewport — a direct child of the
        relative MessageScroller frame — so it floats over the bottom edge. */}
    <MessageScrollerButton direction="end" />
  </MessageScroller>
</MessageScrollerProvider>;

MessageScroller fills its parent, so give it (or that parent) a bounded height and let the viewport do the overflowing.

<div className="flex h-screen flex-col">
  <MessageScrollerProvider>
    <MessageScroller className="flex-1">{/* transcript */}</MessageScroller>
  </MessageScrollerProvider>
</div>
Hey — welcome to the thread!
This viewport auto-scrolls to the latest message.
Scroll up and the jump-to-latest button appears.
Older messages keep their position when more load.
It virtualises long histories with content-visibility.
Try the controls below.

Anatomy

MessageScroller — data-slot="message-scroller"
MessageScrollerButton — data-slot="message-scroller-button"
MessageScrollerContent — data-slot="message-scroller-content"
MessageScrollerItem — data-slot="message-scroller-item"
MessageScrollerProvider
MessageScrollerViewport — data-slot="message-scroller-viewport"
  • MessageScrollerProvider — the headless root. Owns scroll state and the behaviour props: autoScroll, defaultScrollPosition, scrollPreviousItemPeek, scrollMargin, scrollEdgeThreshold.
  • MessageScroller — the styled frame. A relative, clipping flex column.
  • MessageScrollerViewport — the scrollable element. Receives native scroll events, preserves the visible row when older messages are prepended, and fades its bottom edge with scroll-fade.
  • MessageScrollerContent — the transcript container, and the live region for new rows.
  • MessageScrollerItem — one row boundary. Wrap every direct child of the content so the scroller can measure, anchor, preserve position and jump to it. A row can be a message, a marker, a typing indicator or a "load earlier" control.
  • MessageScrollerButton — the scroll control. Renders our Button and is inert until there is content in its direction.

Hooks

  • useMessageScroller() → scrollToEnd(), scrollToStart(), scrollToMessage(id, options).
  • useMessageScrollerScrollable() → { start, end } — whether each edge can scroll.
  • useMessageScrollerVisibility() → { currentAnchorId, visibleMessageIds }.

Examples

What Makes a Great Streaming Chat Experience

Streaming broke the old model. Messages arrive in chunks while the reader may still be reading, scrolling, or looking somewhere else, so the whole problem becomes scroll: when to follow, when to hold, and when to let the reader decide.

Move only when the reader asked to move; follow only while they are following; treat every interaction — scroll, selection, a keystroke, a link — as a signal to stop moving; start a new turn near the top so it can be read from the beginning; keep enough of the previous turn visible that the exchange stays connected; let the rest arrive offscreen; and make coming back to the live edge one obvious action. Never move the reader against their intent.

Hey — welcome to the thread!
This viewport auto-scrolls to the latest message.
Scroll up and the jump-to-latest button appears.
Older messages keep their position when more load.
It virtualises long histories with content-visibility.
Try the controls below.

Scroll up, then receive a reply. With following off the transcript never moves under you — the jump control is how you come back.

MessageScroller

MessageScrollerProvider owns the scroll state and the transcript-row behaviour — opening position, streamed output, new-turn anchoring, prepended history, visibility and the scroll commands. MessageScroller is the styled frame that renders inside it.

It is scoped to the scroll viewport, and to nothing else: it does not own messages, AI state, transport, persistence, branching or model state. Your product code stays focused on composing messages, markers, tools, attachments and prompt inputs.

Turn 1 of the conversation
Turn 2 of the conversation
Turn 3 of the conversation
Turn 4 of the conversation
Turn 5 of the conversation
Turn 6 of the conversation
Turn 7 of the conversation
Turn 8 of the conversation
Turn 9 of the conversation

The provider owns scroll state; the frame owns layout. Neither owns the messages, the transport, or the model — those stay yours.

Composition

MessageScrollerProvider
└── MessageScroller
    ├── MessageScrollerViewport
    │   └── MessageScrollerContent
    │       ├── MessageScrollerItem
    │       ├── MessageScrollerItem
    │       └── MessageScrollerItem
    └── MessageScrollerButton

The button is a sibling of the viewport, not a child of it: it pins to the frame and floats over the edge it scrolls toward.

MessageScrollerItem 1
MessageScrollerItem 2
MessageScrollerItem 3
MessageScrollerItem 4
MessageScrollerItem 5
MessageScrollerItem 6
MessageScrollerItem 7
MessageScrollerItem 8

Dashed: the frame, the viewport it scrolls, and every row boundary. Wrap every direct child of the content in an item so the scroller can measure, anchor and jump to it.

Core Concepts

A turn is the part of the conversation that starts a new exchange — usually the user's message and the reply that follows it. An anchor is the row the viewport should treat as the start of that turn; mark it with scrollAnchor.

// This tells the scroller to anchor the user's message for the next turn.
<MessageScrollerItem
  messageId={message.id}
  scrollAnchor={message.role === "user"}
/>

Anchors are not tied to a message role. Any row can be one — a user message, a system marker, a handoff event — and when a new anchor is appended the viewport moves it near the top and keeps a peek of the previous item above it, so the new turn never feels detached from its context.

You
Can you summarise the release?
Assistant
Sure. Three components landed, two were renamed, and the token ladder was flattened.
You
What changed for consumers?
Assistant
Nothing they have to do today — the copy-in tracks updates through shadcn add --diff.

The anchored row moves near the top of the viewport and the reply grows in below it, instead of the whole thread jumping to the bottom.

Group Chat

In a group chat the turn boundary is more specific than "the user message" — it is the message that asks for a response, or a marker like "Marcus joined the chat". Because anchoring is role-independent, a marker anchors exactly as easily as a message, and a typing indicator or a history control simply does not.

<MessageScrollerItem messageId="marcus-joined" scrollAnchor>
  <Marker variant="separator">
    <MarkerContent>Marcus joined the chat</MarkerContent>
  </Marker>
</MessageScrollerItem>
Standup in five.
On my way.
Same — grabbing coffee.
Who is taking notes?
Marcus joined the chat
Hey all — what did I miss?
Nothing yet. We were about to start.
Marcus is typing…

The join marker is the anchor, so the thread opens on the moment the room changed. The typing row is deliberately not one.

Keeping Context Visible

scrollPreviousItemPeek keeps a slice of the previous item visible above the anchor, so a new turn still reads as part of the same continuous thread instead of restarting on a blank page.

// Keep 64px of the previous turn visible above the newly anchored row.
<MessageScrollerProvider scrollPreviousItemPeek={64}>
  <MessageScroller>{/* anchored turns */}</MessageScroller>
</MessageScrollerProvider>
Turn 1
Turn 2
Turn 3
Turn 4
Turn 5
Turn 6
Turn 7
Turn 8
Turn 9 — the anchored row
Turn 10
Turn 11
Turn 12

At 0 the anchored turn sits flush against the top edge and reads like a fresh page. A peek keeps the previous turn in frame.

Following the Live Edge

With autoScroll, the viewport follows new content — but only while the reader is at the live edge. Set aria-busy on the content while a turn streams so announcements wait for the completed row.

<MessageScrollerContent aria-busy={status === "streaming"}>
  {/* messages */}
</MessageScrollerContent>
Hey — welcome to the thread!
This viewport auto-scrolls to the latest message.
Scroll up and the jump-to-latest button appears.
Older messages keep their position when more load.

Opening Saved Threads

A saved conversation should open at the last meaningful turn, which is usually the last user message rather than the absolute bottom. defaultScrollPosition="last-anchor" restores the last row flagged with scrollAnchor.

Turn 1
Turn 2
Turn 3
Turn 4
Turn 5
Turn 6 — restored into view
Turn 7
Turn 8
Turn 9
Turn 10

Opens at the anchored turn (tinted) rather than the top or bottom, with a peek of the previous message above it.

Avoiding a Flash on Reload

A scroll container always opens at the top and HTML has no way to set scrollTop, so a server-rendered transcript would show the oldest messages and then jump once JavaScript applies defaultScrollPosition. While "end" or "last-anchor" is still pending, the viewport carries data-pending-scroll and the styled recipe keeps it invisible: you see the frame, never the jump. "start" needs none of this.

If you want "end" visible on first paint, give the viewport an id and scroll it from an inline script placed right after it in your page — never inside the scroller — with suppressHydrationWarning on the viewport and a nonce if you run a Content Security Policy. It only works for "end", and only when the messages are already in the HTML.

const scrollToEndScript = `(function () {
  var viewport = document.getElementById("messages")
  if (!viewport) {
    return
  }
  viewport.scrollTop = viewport.scrollHeight
  viewport.removeAttribute("data-pending-scroll")
})()`;

<MessageScroller>
  <MessageScrollerViewport id="messages" suppressHydrationWarning>
    <MessageScrollerContent>{/* transcript */}</MessageScrollerContent>
  </MessageScrollerViewport>
  <script dangerouslySetInnerHTML={{ __html: scrollToEndScript }} />
  <MessageScrollerButton />
</MessageScroller>;
Saved message 1
Saved message 2
Saved message 3
Saved message 4
Saved message 5
Saved message 6
Saved message 7
Saved message 8
Saved message 9
Saved message 10
Saved message 11
Saved message 12
Saved message 13
Saved message 14

A scroll container always opens at the top, so a server-rendered transcript would show the oldest row and then jump. While data-pending-scroll is on the viewport the styled recipe keeps it invisible: you see the frame, never the jump.

Loading Earlier Messages

Prepending older rows must not move the conversation the reader is already looking at. The viewport preserves the visible row while history loads above it — preserveScrollOnPrepend, on by default.

Recent message 1
Recent message 2
Recent message 3
Recent message 4
Recent message 5
Recent message 6
Recent message 7
Recent message 8
Recent message 9
Recent message 10

Rows are prepended above the reader. The viewport preserves the visible row, so history loads without moving what is being read — that is preserveScrollOnPrepend, on by default.

Animating New Messages

MessageScrollerItem animates directly: keep messageId and scrollAnchor on it and use transform and opacity for the entrance. Here the sent row wears motion-enter-up, the system's fade-and-rise arrival, so it looks like it comes from the live edge; base.css collapses it under prefers-reduced-motion.

You
Can you summarise the release?
Assistant
Sure. Three components landed, two were renamed, and the token ladder was flattened.
You
What changed for consumers?
Assistant
Nothing they have to do today — the copy-in tracks updates through shadcn add --diff.

Jumping to Messages

useMessageScroller().scrollToMessage(id, options) drives the transcript from outside — a jump menu, a search result, an unread marker, a permalink.

Message 1
Message 2
Message 3
Message 4
Message 5
Message 6
Message 7
Message 8
Message 9
Message 10
Message 11
Message 12

Tracking the Reader's Position

useMessageScrollerVisibility() reports currentAnchorId and visibleMessageIds. It is pay-for-what-you-use: nothing is tracked until something subscribes.

Turn 1 of the conversation
Turn 2 of the conversation
Turn 3 of the conversation
Turn 4 of the conversation
Turn 5 of the conversation
Turn 6 of the conversation
Turn 7 of the conversation
Turn 8 of the conversation

Reading Scroll State

useMessageScrollerScrollable() reports which edges can still scroll, which is what a second MessageScrollerButton direction="start" — or your own affordance — reads.

Message 1
Message 2
Message 3
Message 4
Message 5
Message 6
Message 7
Message 8
Message 9
Message 10
Message 11
Message 12
Message 13
Message 14

Can scroll up: false · down: false

Performance

The goal is to keep the scroll hot path outside React state: no rerenders for transcript rows, no forced layout on every scroll, and as little off-screen paint as the browser can avoid. Scroll position, anchoring and follow-output are tracked imperatively and mirrored onto the root and viewport as data-* attributes.

MessageScrollerItem ships with content-visibility: auto and contain-intrinsic-size, so rows stay in the DOM for selection, copy, find-in-page, SSR and assistive tech while the browser skips rendering work for rows far outside the viewport. That is comfortable for the expected range of a chat transcript: hundreds to low thousands of turns, markdown and composed rows included.

Turn 1 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 2 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 3 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 4 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 5 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 6 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 7 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 8 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 9 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 10 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 11 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 12 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 13 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 14 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 15 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 16 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 17 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 18 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 19 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 20 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 21 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 22 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 23 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 24 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 25 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 26 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 27 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 28 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 29 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 30 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 31 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 32 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 33 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 34 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 35 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 36 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 37 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 38 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 39 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 40 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 41 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 42 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 43 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 44 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 45 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 46 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 47 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 48 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 49 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 50 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 51 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 52 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 53 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 54 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 55 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 56 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 57 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 58 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 59 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.
Turn 60 — every row stays in the DOM for selection, copy, find-in-page and assistive tech.

Sixty real rows, no virtualiser. Scroll position and anchoring are tracked imperatively, and each item carries content-visibility: auto so the browser skips paint work for rows far outside the viewport.

Virtualization

Virtualisation is deliberately outside the primitive — real DOM rows stay fast well into the thousands of turns, so most transcripts never need it. When one does, use MessageScrollerViewport as the virtualiser's scroll element and let the virtualiser own the rows.

A thousand rows. The virtualiser owns the rows; the viewport is only its scroll element, so the jump control and the scroll state still come from the provider.

Unstyled

The behaviour comes from @shadcn/react/message-scroller. Mount those parts directly when the transcript has to look like something else entirely; the styled components above are that engine plus our token chrome and nothing more.

Unstyled row 1
Unstyled row 2
Unstyled row 3
Unstyled row 4
Unstyled row 5
Unstyled row 6
Unstyled row 7
Unstyled row 8
Unstyled row 9
Unstyled row 10
Unstyled row 11
Unstyled row 12

The same behaviour with none of our chrome: every class here is the example's own. Reach for this when the transcript has to look like something else entirely.

Jump Button Variants

MessageScrollerButton forwards variant and size to Button and accepts custom children in place of the default arrow. It defaults to secondary / icon-sm.

Message 1
Message 2
Message 3
Message 4
Message 5
Message 6
Message 7
Message 8
Message 9
Message 10
Message 11
Message 12
Message 13
Message 14
Message 15
Message 16

Visible now: 0 of 16 messages

API Reference

MessageScrollerProvider

The provider is a pass-through to the @shadcn/react primitive, whose props the docs type extractor cannot introspect — so the rows below are hand-maintained against the engine.

PropTypeDefaultDescription
autoScrollbooleanfalseFollow new content while the reader is at the live edge. Never pulls a reader who has scrolled away.
defaultScrollPosition"start" | "end" | "last-anchor""end"Where the viewport rests on mount: the top, the bottom, or the last row flagged with scrollAnchor.
scrollEdgeThresholdnumber8Distance in px from an edge at which it counts as 'at the edge' for auto-scroll and scrollable state.
scrollPreviousItemPeeknumber64Pixels of the row above the anchor kept visible when a turn is anchored or last-anchor is restored.
scrollMarginnumber0Default margin in px applied when a row is scrolled into view through the hooks.
childrenReact.ReactNode—The MessageScroller tree that reads this scroll state.

MessageScrollerViewport

PropTypeDefaultDescription
preserveScrollOnPrependbooleantrueKeep the visible row in place when older messages are prepended above the transcript.
aria-labelstring"Messages"The scroll region's accessible name. The engine names it Messages unless you say otherwise.
tabIndexnumber0The viewport is a keyboard-focusable scroll region. See Deviations for the flagged unconditional default.

MessageScrollerItem

PropTypeDefaultDescription
messageIdstring—Stable id used to target this row from useMessageScroller().scrollToMessage(id).
scrollAnchorbooleanfalseMark this row as a turn boundary: the anchor to move near the top, and the last-anchor restore target.

MessageScrollerButton

PropTypeDefaultDescription
direction"start" | "end""end"Which edge this control scrolls toward. It is inert while there is nothing in that direction.
behavior"auto" | "smooth""smooth"Scroll behaviour for the click. Forced to auto under prefers-reduced-motion (MOT-5), whatever you pass.
variant"default" | "secondary" | "destructive" | "outline" | "ghost" | "link""secondary"Forwarded to the Button this control renders.
size"xs" | "sm" | "default" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg""icon-sm"Forwarded to the Button this control renders.

MessageScroller and MessageScrollerContent add no props of their own — each accepts everything the underlying <div> accepts, and the content additionally takes aria-busy and aria-relevant.

Accessibility

  • MessageScrollerViewport is a labelled, keyboard-focusable scroll region: the engine gives it role="region", aria-label="Messages" and tabIndex={0}, so a keyboard user can focus the transcript and scroll it directly. Pass your own aria-label when the page has more than one.
  • MessageScrollerContent is the live region — role="log" with aria-relevant="additions" — so new rows can be announced without every streamed token being read out. Set aria-busy while a turn streams and the announcement waits for the completed row (A11Y-3). The component adds no second live region of its own (A11Y-4): one log is the whole announcement surface.
  • MessageScrollerButton is a real button with a visually hidden label ("Scroll to end" / "Scroll to start"). With nothing to scroll toward it sets inert, tabIndex={-1} and data-active="false", so an inactive control is never an extra focus stop.
  • Focus on the viewport is the global 2px :focus-visible outline, pulled inside the frame's clip with focus-visible:-outline-offset-2 (FOC-9) so it is not cut away by overflow-hidden.
  • The jump control's click scroll is forced to behavior: "auto" under prefers-reduced-motion (MOT-5), and the dock-in/dock-out transition collapses with the global reduced-motion reset.
Message 1
Message 2
Message 3
Message 4
Message 5
Message 6
Message 7
Message 8
Message 9
Message 10

Tab into the transcript: the viewport is a labelled role="region" and a tab stop, so a keyboard user can scroll it directly. The content is role="log" with aria-relevant="additions", and aria-busy holds announcements until the turn finishes.

ContractStates tested
Behaviourdefault, active, open
Accessibilitybusy
Visualdefault, hover, active

Do / Don't

Do
Anchor the row that starts a turn and let the reply grow in below it, so the exchange stays readable from its beginning.
Don't
Auto-scroll a reader who has scrolled away — hold their place and give them the jump control instead.

Deviations

Upstream's file plus packages/ui/upstream/patches/message-scroller.patch. Every hunk:

  • FOC-9 — MessageScrollerViewport gains focus-visible:-outline-offset-2. The viewport is a tab stop and MessageScroller above it clips with overflow-hidden, so base.css's outward outline-offset: 1px is drawn and then cut away by the ancestor. This is the one permitted local focus deviation; nothing but the offset moves.
  • MOT-5 — MessageScrollerButton reads usePrefersReducedMotion() and forces the engine's scroll behavior to "auto". The engine defaults it to "smooth" and knows nothing about the preference, and the global reset in base.css cannot reach it: a scrollTo that asks for smooth explicitly outranks the scroll-behavior property. This wrapper is the only place the preference can be honoured, so it overrides whatever the consumer passed.
  • A11Y-3, A11Y-4 — NO HUNK. Measured in @shadcn/react@0.3.1: Content already renders role="log" with aria-relevant="additions", and Viewport renders role="region" with aria-label="Messages". That is A11Y-3 verbatim — a polite region announcing added rows rather than every streamed token, and no alert anywhere — and A11Y-4's one-region rule is satisfied by that single log; a useAnnouncer beside it would announce every message twice. message-scroller.test.tsx asserts both roles and the aria-relevant value, so the day the engine drops them the claim fails instead of going quiet.
  • A11Y-2 — audited, NO HUNK. The one control is MessageScrollerButton, a Button at size="icon-sm" — size-7, 28px, over the floor.
  • NOT PATCHED, FLAGGED FOR MK — the engine writes tabIndex: value ?? 0 on the viewport unconditionally, so a transcript short enough not to scroll is still a tab stop, which is the half of A11Y-6 that Base UI's ScrollArea.Viewport gets right. A11Y-6 is not assigned to this component, its second clause licenses a named role="region" viewport, and the pre-reset component did not condition it either, so nothing is invented here. The test pins the engine's exact shape, so this note fails as stale the day upstream conditions it.
  • DOC-1, DOC-2 — cn is imported from @vegastack/design, and the rest of the diff is prettier's reflow plus the registry-stamp.mjs provenance header the three-copy model needs.

On this page