Message Scroller

A chat transcript scroller that anchors turns, follows streamed replies, and preserves position when history loads.

New Chat

How can I help you today?

Morning, shadcn!
What are we working on today? Press send to start a new conversation
I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.
Demo is read only. Press send to send messages.

Installation

ng g @spartan-ng/cli:ui message-scroller

Usage

import { HlmMessageScrollerImports } from '@spartan-ng/helm/message-scroller';
<div hlmMessageScrollerProvider [autoScroll]="true">
  <div hlmMessageScroller class="h-96">
    <div hlmMessageScrollerViewport>
      <div hlmMessageScrollerContent>
        @for (message of messages; track message.id) {
          <div
            hlmMessageScrollerItem
            [messageId]="message.id"
            [scrollAnchor]="message.role === 'user'"
          >
            <!-- Message / Marker / etc. -->
          </div>
        }
      </div>
    </div>
    <button hlmMessageScrollerButton></button>
  </div>
</div>

MessageScroller owns scroll behavior only. Compose Message , Bubble , and Marker inside each item. Mark turn boundaries with scrollAnchor and enable autoScroll to follow the live edge while the reader stays there.

Core Concepts

Anchoring Turns

Mark the row that should settle near the top of the viewport with scrollAnchor . When a new anchor is appended, the viewport moves it near the top and keeps a peek of the previous item above it.

Anchoring Turns

Choose which role settles near the top edge.

No anchored messages yet
Send the first message to see the selected role anchor.
Toggle the anchor role, then send messages to compare where turns settle.

Group Chat

Anchoring is role-independent. Anchor a marker, handoff event, or any row that starts a meaningful turn — not just user messages.

Group Chat

A group chat with several participants and an assistant. The Marker is marked as a turn.

@mary, the astrophage line keeps matching Venus energy output. Can you check my math?
Mary (Agent)
Yes. Confirmed. The curve points to a microorganism harvesting stellar energy and breeding near carbon dioxide. If @rocky agrees, this is the clue we need.
ping @rocky

This will create a marker and make it the anchor

When a user joins, a marker is created. scrollAnchor on the marker marks it as the next turn

Keeping Context Visible

scrollPreviousItemPeek keeps a slice of the previous item visible above the anchor so the new turn still feels connected to the thread.

Keeping Context Visible

New turns keep part of the previous reply in view.

I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.

That's the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll` — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.

The important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user's intent.

Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.
64px
Adjust the slider and send. Observe the previous message peek

Following the Live Edge

When the reader is at the live edge, autoScroll keeps streamed replies in view as they grow. Scrolling away releases the view until the reader returns.

Streaming Messages

Auto-scroll follows the live edge of the conversation.

Ready to Stream
Press send to stream a scripted launch summary.
I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.
Streaming is simulated. autoScroll is enabled.

Opening Saved Threads

Use defaultScrollPosition to control where a saved transcript opens — "last-anchor" shows the last meaningful turn instead of the absolute bottom.

Opening Position

Choose where a saved transcript opens.

This is the first message the user sent in the conversation.

Workspace creation rose 8%, but first invite completion only rose 2%.

This is the last message the user sent in the conversation.

Start with the invite step. Teams are creating workspaces but waiting to add collaborators.

Recommended follow-up:

1. Compare invite drop-off by account size. 2. Check whether users who skip invites still return within 24 hours. 3. Review the empty-state copy on the first project screen. 4. Segment activation by template, since template users may not need invites right away.

If that pattern holds, the next experiment should make collaboration useful earlier instead of prompting for invites harder.

Toggle the defaultScrollPosition to see where the transcript starts when you open the thread

Loading Earlier Messages

When older rows are prepended above the current transcript, the viewport preserves the visible row so the reader stays in the same place while history loads above them.

Load History

Prepended messages keep your place.

Only the export queue worker changed. The deploy moved large CSV jobs onto the shared retry policy, which made each failed attempt hold a worker slot longer than before.

The app deploy did not include checkout, pricing, or billing API changes.

Do we need to roll back?

Not yet. Queue depth is recovering after we reduced retry concurrency, and the oldest pending job is now under five minutes old.

Keep rollback ready if the queue starts climbing again, but the current trend points toward recovery.

Keep watching for customer-visible issues.

I will watch the queue and support tags for another 15 minutes. I am tracking export failures, delayed download requests, and any support thread that mentions missing reports.

If those stay quiet through the next batch window, we can close this as an internal degradation.

End of Conversation

Restore earlier messages while keeping your place.

Click Load History to load the entire conversation

Animating New Messages

Animate user rows with transform and opacity while assistant replies stream into regular rows below. Avoid animating height, margin, or padding — those fight the scroller's positioning work.

Animation

Choose how user messages are animated when they are added to the conversation.

No Messages Yet
Click the button below to send the first message.
Select an animation then click send to see it in action.

Jumping to Messages

Use injectBrnMessageScroller() for search results, permalinks, outline items, and toolbar buttons that drive the transcript from outside the message list.

Commands

Drive the transcript from outside.

We're seeing activation dip after workspace creation. Can you help me find the likely step?

The sharpest drop is between creating the workspace and inviting the first teammate.

Workspace creation is still healthy, but the invite step is where users pause. That suggests the product is asking for collaboration before the user has enough confidence in the workspace.

What should I compare before we change the onboarding flow?

Compare three cohorts:

1. Users who choose a template before inviting teammates. 2. Users who start from a blank workspace. 3. Users who skip invites and return within 24 hours.

If template users invite faster, the fix is probably better first-run guidance rather than a louder invite prompt.

Can you turn that into an experiment?

Yes. Create a variant that shows a short checklist after workspace creation:

- Pick a template. - Add one project detail. - Invite a teammate when the workspace has context.

Measure first invite completion, 24-hour return rate, and whether teams create a second project.

What's the risk if we delay the invite prompt?

The main risk is reducing team creation for accounts that already know who they want to invite.

To protect that path, keep the invite action visible in the header and only change the primary empty-state guidance. That gives confident teams a direct route without forcing uncertain users through the invite step too early.

Use the controls to jump to any message in the conversation.

Tracking the Reader's Position

Call observeVisibility() and read the visibility signal to highlight the current anchored turn or build a table of contents.

Transcript Outline

Track the current anchored turn.

Review the incident handoff and tell me what to read first.

Start with the summary and the impact section. The regression affected the upload queue, but the recovery path completed for every queued job.

What was the customer impact?

Impact was limited to delayed processing.

No records were dropped, and the reconciliation worker confirmed each retry batch. Support saw confusion from two customers, but there were no checkout or billing errors.

What actions are open?

Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.

The alert should fire on sustained queue growth, not a single short spike.

Give me the follow-up checklist.

After that, compare the queue recovery graph with the deploy timeline so the handoff shows exactly when processing returned to baseline. That makes it easier for support and engineering to answer the same customer questions without re-reading the whole incident thread.

I would also add a short owner note beside each follow-up item. The checklist is small, but ownership keeps the retry-window decision, alert tuning, and support macro from drifting into separate follow-up conversations.

Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.

The alert should fire on sustained queue growth, not a single short spike.

Open the outline to jump between anchored turns as you read.

Reading Scroll State

The scrollable signal reports which edges the viewport can still scroll toward. For styling the scroller itself, prefer the data-scrollable attribute.

Scroll Status

Where the reader can go scroll to based on current scroll position.

Review scroll checkpoint 1.

Checkpoint 2 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 3.

Checkpoint 4 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 5.

Checkpoint 6 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 7.

Checkpoint 8 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 9.

Checkpoint 10 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 11.

Checkpoint 12 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

All messages fit in the viewport.
Scroll the transcript to see the footer update.

Brain API

BrnMessageScrollerButton

Selector: button[brnMessageScrollerButton]

ExportAs: brnMessageScrollerButton

Inputs

PropTypeDefaultDescription
direction BrnMessageScrollerButtonDirection 'end' Transcript edge to scroll toward.
behavior ScrollBehavior 'smooth' Native scroll behavior when clicked.
aria-label string | undefined undefined Accessible name for the control. When omitted, defaults from `direction` (`Scroll to end` / `Scroll to start`).

BrnMessageScrollerContent

Selector: [brnMessageScrollerContent],brn-message-scroller-content

ExportAs: brnMessageScrollerContent

Inputs

PropTypeDefaultDescription
aria-relevant unknown 'additions' aria-relevant for the live log region.
spacerClassName string | undefined undefined Optional class name applied to the internal tail spacer.

BrnMessageScrollerItem

Selector: [brnMessageScrollerItem],brn-message-scroller-item

ExportAs: brnMessageScrollerItem

Inputs

PropTypeDefaultDescription
messageId string | undefined undefined Stable row id for scrollToMessage, visibility, and prepend preservation.
scrollAnchor boolean false Marks a turn boundary that newly appended anchors and last-anchor restore use.

BrnMessageScrollerProvider

Selector: [brnMessageScrollerProvider]

ExportAs: brnMessageScrollerProvider

Inputs

PropTypeDefaultDescription
autoScroll boolean false Follow new content at the bottom while the viewport is already at the end.
defaultScrollPosition BrnMessageScrollerDefaultScrollPosition 'end' Opening position on the first non-empty render, applied once.
scrollEdgeThreshold number DEFAULT_SCROLL_EDGE_THRESHOLD Distance from an edge that still counts as at-top/at-bottom.
scrollPreviousItemPeek number DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK Extra top margin for a newly anchored row, added to scrollMargin.
scrollMargin number DEFAULT_SCROLL_MARGIN Default margin on the aligned edge for commands and visibility.

BrnMessageScrollerRoot

Selector: [brnMessageScroller],brn-message-scroller

ExportAs: brnMessageScroller

BrnMessageScrollerViewport

Selector: [brnMessageScrollerViewport],brn-message-scroller-viewport

ExportAs: brnMessageScrollerViewport

Inputs

PropTypeDefaultDescription
preserveScrollOnPrepend boolean true Keep the first visible messageId row stable on prepend.
aria-label unknown 'Messages' Accessible name for the scroll region.
tabindex number 0 Tab index for keyboard scrolling.

Helm API

HlmMessageScrollerButton

Selector: button[hlmMessageScrollerButton]

Inputs

PropTypeDefaultDescription
variant ButtonVariants['variant'] this._config.variant -
size ButtonVariants['size'] this._config.size -
direction BrnMessageScrollerButtonDirection 'end' Transcript edge to scroll toward.
behavior ScrollBehavior 'smooth' Native scroll behavior when clicked.
aria-label string | undefined undefined Accessible name for the control. When omitted, defaults from `direction` (`Scroll to end` / `Scroll to start`).

HlmMessageScrollerContent

Selector: [hlmMessageScrollerContent],hlm-message-scroller-content

Inputs

PropTypeDefaultDescription
aria-relevant unknown 'additions' aria-relevant for the live log region.
spacerClassName string | undefined undefined Optional class name applied to the internal tail spacer.

HlmMessageScrollerItem

Selector: [hlmMessageScrollerItem],hlm-message-scroller-item

Inputs

PropTypeDefaultDescription
messageId string | undefined undefined Stable row id for scrollToMessage, visibility, and prepend preservation.
scrollAnchor boolean false Marks a turn boundary that newly appended anchors and last-anchor restore use.

HlmMessageScrollerProvider

Selector: [hlmMessageScrollerProvider]

Inputs

PropTypeDefaultDescription
autoScroll boolean false Follow new content at the bottom while the viewport is already at the end.
defaultScrollPosition BrnMessageScrollerDefaultScrollPosition 'end' Opening position on the first non-empty render, applied once.
scrollEdgeThreshold number DEFAULT_SCROLL_EDGE_THRESHOLD Distance from an edge that still counts as at-top/at-bottom.
scrollPreviousItemPeek number DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK Extra top margin for a newly anchored row, added to scrollMargin.
scrollMargin number DEFAULT_SCROLL_MARGIN Default margin on the aligned edge for commands and visibility.

HlmMessageScrollerViewport

Selector: [hlmMessageScrollerViewport],hlm-message-scroller-viewport

Inputs

PropTypeDefaultDescription
preserveScrollOnPrepend boolean true Keep the first visible messageId row stable on prepend.
aria-label unknown 'Messages' Accessible name for the scroll region.
tabindex number 0 Tab index for keyboard scrolling.

HlmMessageScroller

Selector: [hlmMessageScroller],hlm-message-scroller

Native Select Message