- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Attachment
- Autocomplete
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chart
- Checkbox
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input Group
- Input OTP
- Input
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Native Select
- Navigation Menu
- Pagination
- Popover
- Progress
- Questionnaire
- Radio Group
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner (Toast)
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toggle
- Toggle Group
- Tooltip
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?
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideArrowUp,
lucideGlobe,
lucideImage,
lucideMessageCircleDashed,
lucidePaperclip,
lucidePlus,
lucideRotateCw,
lucideTelescope,
} from '@ng-icons/lucide';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmDropdownMenuImports } from '@spartan-ng/helm/dropdown-menu';
import { HlmEmptyImports } from '@spartan-ng/helm/empty';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
import { HlmMessageScrollerImports } from '@spartan-ng/helm/message-scroller';
import { HlmTooltipImports } from '@spartan-ng/helm/tooltip';
import { MessageScrollerTranscriptRow } from './message-scroller-transcript-row';
import { MAIN_CHAT_SCRIPT, ScriptedChatRunner } from './message-scroller.shared';
@Component({
selector: 'spartan-message-scroller-preview',
imports: [
HlmMessageScrollerImports,
HlmCardImports,
HlmButtonImports,
HlmEmptyImports,
HlmInputGroupImports,
HlmDropdownMenuImports,
HlmTooltipImports,
MessageScrollerTranscriptRow,
NgIcon,
],
providers: [
provideIcons({
lucideArrowUp,
lucideGlobe,
lucideImage,
lucideMessageCircleDashed,
lucidePaperclip,
lucidePlus,
lucideRotateCw,
lucideTelescope,
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'relative flex w-full max-w-sm flex-col gap-4',
},
template: `
<div hlmMessageScrollerProvider>
<div class="relative flex flex-col gap-4">
<hlm-card class="mx-auto h-[35rem] w-full max-w-sm gap-0">
<hlm-card-header class="gap-1 border-b">
<h3 hlmCardTitle>New Chat</h3>
<p hlmCardDescription>How can I help you today?</p>
<div hlmCardAction>
<button
hlmBtn
type="button"
variant="outline"
size="icon"
hlmTooltip="Reset"
aria-label="Reset conversation"
[disabled]="_chat.isBusy()"
(click)="reset()"
>
<ng-icon name="lucideRotateCw" />
</button>
</div>
</hlm-card-header>
<div hlmCardContent class="min-h-0 flex-1 overflow-hidden p-0">
@if (_chat.messages().length === 0) {
<hlm-empty class="h-full">
<hlm-empty-header>
<hlm-empty-media variant="icon">
<ng-icon name="lucideMessageCircleDashed" />
</hlm-empty-media>
<div hlmEmptyTitle>Morning, shadcn!</div>
<div hlmEmptyDescription>What are we working on today? Press send to start a new conversation</div>
</hlm-empty-header>
</hlm-empty>
} @else {
<div hlmMessageScroller>
<div hlmMessageScrollerViewport>
<div
hlmMessageScrollerContent
class="p-(--card-spacing)"
[attr.aria-busy]="_chat.isBusy() ? 'true' : null"
>
@for (message of _chat.messages(); track message.id) {
<spartan-message-scroller-transcript-row
[messageId]="message.id"
[role]="message.role"
[text]="message.text"
[scrollAnchor]="message.role === 'user'"
[animationClass]="
message.role === 'user'
? 'motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 duration-300'
: ''
"
/>
}
</div>
</div>
<button hlmMessageScrollerButton></button>
</div>
}
</div>
<hlm-card-footer class="flex-col gap-2">
<form class="w-full" (submit)="send($event)">
<hlm-input-group>
<div class="h-14 w-full px-3 py-2.5">
<span
class="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
[attr.data-status]="_chat.status()"
>
@if (_nextPreview(); as preview) {
{{ preview }}
} @else {
<span class="text-muted-foreground">No messages queued. Reset the conversation.</span>
}
</span>
</div>
<hlm-input-group-addon align="block-end" class="pt-1">
<button
hlmInputGroupButton
type="button"
size="icon-sm"
variant="outline"
aria-label="Add files"
[hlmDropdownMenuTrigger]="attachmentMenu"
align="start"
side="top"
>
<ng-icon name="lucidePlus" />
</button>
<button
hlmInputGroupButton
type="submit"
variant="default"
size="icon-sm"
class="ms-auto"
[disabled]="!_chat.nextMessage() || _chat.isBusy()"
>
<ng-icon name="lucideArrowUp" />
<span class="sr-only">Send</span>
</button>
</hlm-input-group-addon>
</hlm-input-group>
</form>
</hlm-card-footer>
</hlm-card>
<div class="text-muted-foreground px-0.5 text-center text-xs">
Demo is read only. Press send to send messages.
</div>
</div>
</div>
<ng-template #attachmentMenu>
<hlm-dropdown-menu class="w-44">
<button hlmDropdownMenuItem>
<ng-icon name="lucidePaperclip" />
Add Photos & Files
</button>
<div hlmDropdownMenuSeparator></div>
<button hlmDropdownMenuItem>
<ng-icon name="lucideImage" />
Create Image
</button>
<button hlmDropdownMenuItem>
<ng-icon name="lucideTelescope" />
Deep Research
</button>
<button hlmDropdownMenuItem>
<ng-icon name="lucideGlobe" />
Web Search
</button>
</hlm-dropdown-menu>
</ng-template>
`,
})
export class MessageScrollerPreview {
protected readonly _chat = new ScriptedChatRunner(MAIN_CHAT_SCRIPT, {
streamDelayMs: 20,
thinkDelayMs: 1000,
});
protected readonly _nextPreview = computed(() => this._chat.nextMessage()?.text ?? null);
protected reset(): void {
this._chat.reset();
}
protected send(event: Event): void {
event.preventDefault();
if (!this._chat.nextMessage() || this._chat.isBusy()) {
return;
}
void this._chat.sendNext();
}
}Installation
ng g @spartan-ng/cli:ui message-scrollernx g @spartan-ng/cli:ui message-scrollerimport { DestroyRef, ElementRef, HostAttributeToken, Injector, PLATFORM_ID, effect, inject, makeEnvironmentProviders, runInInjectionContext, type EnvironmentProviders } from '@angular/core';
import { OVERLAY_DEFAULT_CONFIG } from '@angular/cdk/overlay';
import { clsx, type ClassValue } from 'clsx';
import { isPlatformBrowser } from '@angular/common';
import { provideSpartanHlm } from '@spartan-ng/helm/utils';
import { twMerge } from 'tailwind-merge';
export function hlm(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Global map to track class managers per element
const elementClassManagers = new WeakMap<HTMLElement, ElementClassManager>();
// Global mutation observer for all elements
let globalObserver: MutationObserver | null = null;
const observedElements = new Set<HTMLElement>();
interface ElementClassManager {
element: HTMLElement;
sources: Map<number, { classes: Set<string>; order: number }>;
baseClasses: Set<string>;
isUpdating: boolean;
nextOrder: number;
hasInitialized: boolean;
restoreRafId: number | null;
/** Transitions are suppressed until the first effect writes correct classes */
transitionsSuppressed: boolean;
/** Original inline transition value to restore after suppression (empty string = none was set) */
previousTransition: string;
/** Original inline transition priority to preserve !important when restoring */
previousTransitionPriority: string;
}
let sourceCounter = 0;
/**
* This function dynamically adds and removes classes for a given element without requiring
* the a class binding (e.g. `[class]="..."`) which may interfere with other class bindings.
*
* 1. This will merge the existing classes on the element with the new classes.
* 2. It will also remove any classes that were previously added by this function but are no longer present in the new classes.
* 3. Multiple calls to this function on the same element will be merged efficiently.
*/
export function classes(computed: () => ClassValue[] | string, options: ClassesOptions = {}) {
runInInjectionContext(options.injector ?? inject(Injector), () => {
const elementRef = options.elementRef ?? inject(ElementRef);
const platformId = inject(PLATFORM_ID);
const destroyRef = inject(DestroyRef);
const baseClasses = inject(new HostAttributeToken('class'), { optional: true });
const element = elementRef.nativeElement;
// Create unique identifier for this source
const sourceId = sourceCounter++;
// Get or create the class manager for this element
let manager = elementClassManagers.get(element);
if (!manager) {
// Initialize base classes from variation (host attribute 'class')
const initialBaseClasses = new Set<string>();
if (baseClasses) {
toClassList(baseClasses).forEach((cls) => initialBaseClasses.add(cls));
}
manager = {
element,
sources: new Map(),
baseClasses: initialBaseClasses,
isUpdating: false,
nextOrder: 0,
hasInitialized: false,
restoreRafId: null,
transitionsSuppressed: false,
previousTransition: '',
previousTransitionPriority: '',
};
elementClassManagers.set(element, manager);
// Setup global observer if needed and register this element
setupGlobalObserver(platformId);
observedElements.add(element);
// Suppress transitions until the first effect writes correct classes and
// the browser has painted them. This prevents CSS transition animations
// during hydration when classes change from SSR state to client state.
if (isPlatformBrowser(platformId)) {
manager.previousTransition = element.style.getPropertyValue('transition');
manager.previousTransitionPriority = element.style.getPropertyPriority('transition');
element.style.setProperty('transition', 'none', 'important');
manager.transitionsSuppressed = true;
}
}
// Assign order once at registration time
const sourceOrder = manager.nextOrder++;
function updateClasses(): void {
// Get the new classes from the computed function
const newClasses = toClassList(computed());
// Update this source's classes, keeping the original order
manager!.sources.set(sourceId, {
classes: new Set(newClasses),
order: sourceOrder,
});
// Update the element
updateElement(manager!);
// Re-enable transitions after the first effect writes correct classes.
// Deferred to next animation frame so the browser paints the class change
// with transitions disabled first, then re-enables them.
if (manager!.transitionsSuppressed) {
manager!.transitionsSuppressed = false;
manager!.restoreRafId = requestAnimationFrame(() => {
manager!.restoreRafId = null;
restoreTransitionSuppression(manager!);
});
}
}
// Register cleanup with DestroyRef
destroyRef.onDestroy(() => {
if (manager!.restoreRafId !== null) {
cancelAnimationFrame(manager!.restoreRafId);
manager!.restoreRafId = null;
}
if (manager!.transitionsSuppressed) {
manager!.transitionsSuppressed = false;
restoreTransitionSuppression(manager!);
}
// Remove this source from the manager
manager!.sources.delete(sourceId);
// If no more sources, clean up the manager
if (manager!.sources.size === 0) {
cleanupManager(element);
} else {
// Update element without this source's classes
updateElement(manager!);
}
});
/**
* We need this effect to track changes to the computed classes. Ideally, we would use
* afterRenderEffect here, but that doesn't run in SSR contexts, so we use a standard
* effect which works in both browser and SSR.
*/
effect(updateClasses);
});
}
function restoreTransitionSuppression(manager: ElementClassManager): void {
const prev = manager.previousTransition;
if (prev) {
manager.element.style.setProperty('transition', prev, manager.previousTransitionPriority || undefined);
} else {
manager.element.style.removeProperty('transition');
}
}
// eslint-disable-next-line @typescript-eslint/no-wrapper-object-types
function setupGlobalObserver(platformId: Object): void {
if (isPlatformBrowser(platformId) && !globalObserver) {
// Create single global observer that watches the entire document
globalObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const element = mutation.target as HTMLElement;
const manager = elementClassManagers.get(element);
// Only process elements we're managing
if (manager && observedElements.has(element)) {
if (manager.isUpdating) continue; // Ignore changes we're making
// Update base classes to include any externally added classes
const currentClasses = toClassList(element.className);
const allSourceClasses = new Set<string>();
// Collect all classes from all sources
for (const source of manager.sources.values()) {
for (const className of source.classes) {
allSourceClasses.add(className);
}
}
// Any classes not from sources become new base classes
manager.baseClasses.clear();
for (const className of currentClasses) {
if (!allSourceClasses.has(className)) {
manager.baseClasses.add(className);
}
}
updateElement(manager);
}
}
}
});
// Start observing the entire document for class attribute changes
globalObserver.observe(document, {
attributes: true,
attributeFilter: ['class'],
subtree: true, // Watch all descendants
});
}
}
function updateElement(manager: ElementClassManager): void {
if (manager.isUpdating) return; // Prevent recursive updates
manager.isUpdating = true;
// Handle initialization: capture base classes after first source registration
if (!manager.hasInitialized && manager.sources.size > 0) {
// Get current classes on element (may include SSR classes)
const currentClasses = toClassList(manager.element.className);
// Get all classes that will be applied by sources
const allSourceClasses = new Set<string>();
for (const source of manager.sources.values()) {
source.classes.forEach((className) => allSourceClasses.add(className));
}
// Only consider classes as "base" if they're not produced by any source
// This prevents SSR-rendered classes from being preserved as base classes
currentClasses.forEach((className) => {
if (!allSourceClasses.has(className)) {
manager.baseClasses.add(className);
}
});
manager.hasInitialized = true;
}
// Get classes from all sources, sorted by registration order (later takes precedence)
const sortedSources = Array.from(manager.sources.entries()).sort(([, a], [, b]) => a.order - b.order);
const allSourceClasses: string[] = [];
for (const [, source] of sortedSources) {
allSourceClasses.push(...source.classes);
}
// Combine base classes with all source classes, ensuring base classes take precedence
const classesToApply =
allSourceClasses.length > 0 || manager.baseClasses.size > 0
? hlm([...allSourceClasses, ...manager.baseClasses])
: '';
// Apply the classes to the element
if (manager.element.className !== classesToApply) {
manager.element.className = classesToApply;
}
manager.isUpdating = false;
}
function cleanupManager(element: HTMLElement): void {
// Remove from global tracking
observedElements.delete(element);
elementClassManagers.delete(element);
// If no more elements being tracked, cleanup global observer
if (observedElements.size === 0 && globalObserver) {
globalObserver.disconnect();
globalObserver = null;
}
}
interface ClassesOptions {
elementRef?: ElementRef<HTMLElement>;
injector?: Injector;
}
// Cache for parsed class lists to avoid repeated string operations
const classListCache = new Map<string, string[]>();
function toClassList(className: string | ClassValue[]): string[] {
// For simple string inputs, use cache to avoid repeated parsing
if (typeof className === 'string' && classListCache.has(className)) {
return classListCache.get(className)!;
}
const result = clsx(className)
.split(' ')
.filter((c) => c.length > 0);
// Cache string results, but limit cache size to prevent memory growth
if (typeof className === 'string' && classListCache.size < 1000) {
classListCache.set(className, result);
}
return result;
}
/**
* Provides default configuration for Spartan Helm components.
*
* This utility configures the Angular CDK overlay to disable the `usePopover`
* behavior introduced in Angular 21, which causes CDK overlay-based components
* (sheets, dialogs, tooltips, etc.) to render above `position: fixed` elements
* like `<hlm-toaster>`.
*
* @returns {EnvironmentProviders} Environment providers to be added to the application config.
*
* @example
* ```ts
* // app.config.ts
*
*
* export const appConfig: ApplicationConfig = {
* providers: [
* provideSpartanHlm(),
* // ... other providers
* ],
* };
* ```
*/
export function provideSpartanHlm(): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: OVERLAY_DEFAULT_CONFIG,
useValue: { usePopover: false },
},
]);
}import { BrnMessageScrollerButton, BrnMessageScrollerContent, BrnMessageScrollerItem, BrnMessageScrollerProvider, BrnMessageScrollerRoot, BrnMessageScrollerViewport } from '@spartan-ng/brain/message-scroller';
import { ChangeDetectionStrategy, Component, Directive, inject } from '@angular/core';
import { HlmButton, provideBrnButtonConfig } from '@spartan-ng/helm/button';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideArrowDown } from '@ng-icons/lucide';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector on native button
selector: 'button[hlmMessageScrollerButton]',
imports: [NgIcon],
providers: [provideIcons({ lucideArrowDown }), provideBrnButtonConfig({ variant: 'secondary', size: 'icon-sm' })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{ directive: HlmButton, inputs: ['variant', 'size'] },
{
directive: BrnMessageScrollerButton,
inputs: ['direction', 'behavior', 'aria-label'],
},
],
host: {
'data-slot': 'message-scroller-button',
},
template: `
<ng-icon name="lucideArrowDown" />
<span class="sr-only">{{ _brn.resolvedAriaLabel() }}</span>
`,
})
export class HlmMessageScrollerButton {
private readonly _button = inject(HlmButton);
protected readonly _brn = inject(BrnMessageScrollerButton);
constructor() {
this._button.setClass(
'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_ng-icon]:rotate-180',
);
}
}
@Directive({
selector: '[hlmMessageScrollerContent],hlm-message-scroller-content',
hostDirectives: [
{
directive: BrnMessageScrollerContent,
inputs: ['aria-relevant', 'spacerClassName'],
},
],
host: {
'data-slot': 'message-scroller-content',
},
})
export class HlmMessageScrollerContent {
constructor() {
classes(() => 'gap-6 flex h-max min-h-full flex-col');
}
}
@Directive({
selector: '[hlmMessageScrollerItem],hlm-message-scroller-item',
hostDirectives: [
{
directive: BrnMessageScrollerItem,
inputs: ['messageId', 'scrollAnchor'],
},
],
host: {
'data-slot': 'message-scroller-item',
},
})
export class HlmMessageScrollerItem {
constructor() {
classes(() => 'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]');
}
}
@Directive({
selector: '[hlmMessageScrollerProvider]',
hostDirectives: [
{
directive: BrnMessageScrollerProvider,
inputs: ['autoScroll', 'defaultScrollPosition', 'scrollEdgeThreshold', 'scrollPreviousItemPeek', 'scrollMargin'],
},
],
host: {
// Match React's Provider (no layout box) so height:100% on the scroller
// resolves against the real parent (e.g. card content), not this wrapper.
// Inline style — don't depend on a Tailwind `contents` utility being generated.
style: 'display: contents',
},
})
export class HlmMessageScrollerProvider {}
@Directive({
selector: '[hlmMessageScrollerViewport],hlm-message-scroller-viewport',
hostDirectives: [
{
directive: BrnMessageScrollerViewport,
inputs: ['preserveScrollOnPrepend', 'aria-label', 'tabindex'],
},
],
host: {
'data-slot': 'message-scroller-viewport',
},
})
export class HlmMessageScrollerViewport {
constructor() {
classes(
() =>
'scroll-fade-b size-full min-h-0 min-w-0 scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent',
);
}
}
@Directive({
selector: '[hlmMessageScroller],hlm-message-scroller',
hostDirectives: [{ directive: BrnMessageScrollerRoot }],
host: {
'data-slot': 'message-scroller',
},
})
export class HlmMessageScroller {
constructor() {
classes(() => 'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden');
}
}
export const HlmMessageScrollerImports = [
HlmMessageScrollerProvider,
HlmMessageScroller,
HlmMessageScrollerViewport,
HlmMessageScrollerContent,
HlmMessageScrollerItem,
HlmMessageScrollerButton,
] as const;import { BrnMessageScrollerButton, BrnMessageScrollerContent, BrnMessageScrollerItem, BrnMessageScrollerProvider, BrnMessageScrollerRoot, BrnMessageScrollerViewport } from '@spartan-ng/brain/message-scroller';
import { ChangeDetectionStrategy, Component, Directive, inject } from '@angular/core';
import { HlmButton, provideBrnButtonConfig } from '@spartan-ng/helm/button';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideArrowDown } from '@ng-icons/lucide';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector on native button
selector: 'button[hlmMessageScrollerButton]',
imports: [NgIcon],
providers: [provideIcons({ lucideArrowDown }), provideBrnButtonConfig({ variant: 'secondary', size: 'icon-sm' })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{ directive: HlmButton, inputs: ['variant', 'size'] },
{
directive: BrnMessageScrollerButton,
inputs: ['direction', 'behavior', 'aria-label'],
},
],
host: {
'data-slot': 'message-scroller-button',
},
template: `
<ng-icon name="lucideArrowDown" />
<span class="sr-only">{{ _brn.resolvedAriaLabel() }}</span>
`,
})
export class HlmMessageScrollerButton {
private readonly _button = inject(HlmButton);
protected readonly _brn = inject(BrnMessageScrollerButton);
constructor() {
this._button.setClass(
'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_ng-icon]:rotate-180',
);
}
}
@Directive({
selector: '[hlmMessageScrollerContent],hlm-message-scroller-content',
hostDirectives: [
{
directive: BrnMessageScrollerContent,
inputs: ['aria-relevant', 'spacerClassName'],
},
],
host: {
'data-slot': 'message-scroller-content',
},
})
export class HlmMessageScrollerContent {
constructor() {
classes(() => 'gap-8 flex h-max min-h-full flex-col');
}
}
@Directive({
selector: '[hlmMessageScrollerItem],hlm-message-scroller-item',
hostDirectives: [
{
directive: BrnMessageScrollerItem,
inputs: ['messageId', 'scrollAnchor'],
},
],
host: {
'data-slot': 'message-scroller-item',
},
})
export class HlmMessageScrollerItem {
constructor() {
classes(() => 'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]');
}
}
@Directive({
selector: '[hlmMessageScrollerProvider]',
hostDirectives: [
{
directive: BrnMessageScrollerProvider,
inputs: ['autoScroll', 'defaultScrollPosition', 'scrollEdgeThreshold', 'scrollPreviousItemPeek', 'scrollMargin'],
},
],
host: {
// Match React's Provider (no layout box) so height:100% on the scroller
// resolves against the real parent (e.g. card content), not this wrapper.
// Inline style — don't depend on a Tailwind `contents` utility being generated.
style: 'display: contents',
},
})
export class HlmMessageScrollerProvider {}
@Directive({
selector: '[hlmMessageScrollerViewport],hlm-message-scroller-viewport',
hostDirectives: [
{
directive: BrnMessageScrollerViewport,
inputs: ['preserveScrollOnPrepend', 'aria-label', 'tabindex'],
},
],
host: {
'data-slot': 'message-scroller-viewport',
},
})
export class HlmMessageScrollerViewport {
constructor() {
classes(
() =>
'scroll-fade-b size-full min-h-0 min-w-0 scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent',
);
}
}
@Directive({
selector: '[hlmMessageScroller],hlm-message-scroller',
hostDirectives: [{ directive: BrnMessageScrollerRoot }],
host: {
'data-slot': 'message-scroller',
},
})
export class HlmMessageScroller {
constructor() {
classes(() => 'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden');
}
}
export const HlmMessageScrollerImports = [
HlmMessageScrollerProvider,
HlmMessageScroller,
HlmMessageScrollerViewport,
HlmMessageScrollerContent,
HlmMessageScrollerItem,
HlmMessageScrollerButton,
] as const;import { BrnMessageScrollerButton, BrnMessageScrollerContent, BrnMessageScrollerItem, BrnMessageScrollerProvider, BrnMessageScrollerRoot, BrnMessageScrollerViewport } from '@spartan-ng/brain/message-scroller';
import { ChangeDetectionStrategy, Component, Directive, inject } from '@angular/core';
import { HlmButton, provideBrnButtonConfig } from '@spartan-ng/helm/button';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideArrowDown } from '@ng-icons/lucide';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector on native button
selector: 'button[hlmMessageScrollerButton]',
imports: [NgIcon],
providers: [provideIcons({ lucideArrowDown }), provideBrnButtonConfig({ variant: 'secondary', size: 'icon-sm' })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{ directive: HlmButton, inputs: ['variant', 'size'] },
{
directive: BrnMessageScrollerButton,
inputs: ['direction', 'behavior', 'aria-label'],
},
],
host: {
'data-slot': 'message-scroller-button',
},
template: `
<ng-icon name="lucideArrowDown" />
<span class="sr-only">{{ _brn.resolvedAriaLabel() }}</span>
`,
})
export class HlmMessageScrollerButton {
private readonly _button = inject(HlmButton);
protected readonly _brn = inject(BrnMessageScrollerButton);
constructor() {
this._button.setClass(
'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_ng-icon]:rotate-180',
);
}
}
@Directive({
selector: '[hlmMessageScrollerContent],hlm-message-scroller-content',
hostDirectives: [
{
directive: BrnMessageScrollerContent,
inputs: ['aria-relevant', 'spacerClassName'],
},
],
host: {
'data-slot': 'message-scroller-content',
},
})
export class HlmMessageScrollerContent {
constructor() {
classes(() => 'gap-6 flex h-max min-h-full flex-col');
}
}
@Directive({
selector: '[hlmMessageScrollerItem],hlm-message-scroller-item',
hostDirectives: [
{
directive: BrnMessageScrollerItem,
inputs: ['messageId', 'scrollAnchor'],
},
],
host: {
'data-slot': 'message-scroller-item',
},
})
export class HlmMessageScrollerItem {
constructor() {
classes(() => 'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]');
}
}
@Directive({
selector: '[hlmMessageScrollerProvider]',
hostDirectives: [
{
directive: BrnMessageScrollerProvider,
inputs: ['autoScroll', 'defaultScrollPosition', 'scrollEdgeThreshold', 'scrollPreviousItemPeek', 'scrollMargin'],
},
],
host: {
// Match React's Provider (no layout box) so height:100% on the scroller
// resolves against the real parent (e.g. card content), not this wrapper.
// Inline style — don't depend on a Tailwind `contents` utility being generated.
style: 'display: contents',
},
})
export class HlmMessageScrollerProvider {}
@Directive({
selector: '[hlmMessageScrollerViewport],hlm-message-scroller-viewport',
hostDirectives: [
{
directive: BrnMessageScrollerViewport,
inputs: ['preserveScrollOnPrepend', 'aria-label', 'tabindex'],
},
],
host: {
'data-slot': 'message-scroller-viewport',
},
})
export class HlmMessageScrollerViewport {
constructor() {
classes(
() =>
'scroll-fade-b size-full min-h-0 min-w-0 scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent',
);
}
}
@Directive({
selector: '[hlmMessageScroller],hlm-message-scroller',
hostDirectives: [{ directive: BrnMessageScrollerRoot }],
host: {
'data-slot': 'message-scroller',
},
})
export class HlmMessageScroller {
constructor() {
classes(() => 'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden');
}
}
export const HlmMessageScrollerImports = [
HlmMessageScrollerProvider,
HlmMessageScroller,
HlmMessageScrollerViewport,
HlmMessageScrollerContent,
HlmMessageScrollerItem,
HlmMessageScrollerButton,
] as const;import { BrnMessageScrollerButton, BrnMessageScrollerContent, BrnMessageScrollerItem, BrnMessageScrollerProvider, BrnMessageScrollerRoot, BrnMessageScrollerViewport } from '@spartan-ng/brain/message-scroller';
import { ChangeDetectionStrategy, Component, Directive, inject } from '@angular/core';
import { HlmButton, provideBrnButtonConfig } from '@spartan-ng/helm/button';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideArrowDown } from '@ng-icons/lucide';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector on native button
selector: 'button[hlmMessageScrollerButton]',
imports: [NgIcon],
providers: [provideIcons({ lucideArrowDown }), provideBrnButtonConfig({ variant: 'secondary', size: 'icon-sm' })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{ directive: HlmButton, inputs: ['variant', 'size'] },
{
directive: BrnMessageScrollerButton,
inputs: ['direction', 'behavior', 'aria-label'],
},
],
host: {
'data-slot': 'message-scroller-button',
},
template: `
<ng-icon name="lucideArrowDown" />
<span class="sr-only">{{ _brn.resolvedAriaLabel() }}</span>
`,
})
export class HlmMessageScrollerButton {
private readonly _button = inject(HlmButton);
protected readonly _brn = inject(BrnMessageScrollerButton);
constructor() {
this._button.setClass(
'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_ng-icon]:rotate-180',
);
}
}
@Directive({
selector: '[hlmMessageScrollerContent],hlm-message-scroller-content',
hostDirectives: [
{
directive: BrnMessageScrollerContent,
inputs: ['aria-relevant', 'spacerClassName'],
},
],
host: {
'data-slot': 'message-scroller-content',
},
})
export class HlmMessageScrollerContent {
constructor() {
classes(() => 'gap-8 flex h-max min-h-full flex-col');
}
}
@Directive({
selector: '[hlmMessageScrollerItem],hlm-message-scroller-item',
hostDirectives: [
{
directive: BrnMessageScrollerItem,
inputs: ['messageId', 'scrollAnchor'],
},
],
host: {
'data-slot': 'message-scroller-item',
},
})
export class HlmMessageScrollerItem {
constructor() {
classes(() => 'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]');
}
}
@Directive({
selector: '[hlmMessageScrollerProvider]',
hostDirectives: [
{
directive: BrnMessageScrollerProvider,
inputs: ['autoScroll', 'defaultScrollPosition', 'scrollEdgeThreshold', 'scrollPreviousItemPeek', 'scrollMargin'],
},
],
host: {
// Match React's Provider (no layout box) so height:100% on the scroller
// resolves against the real parent (e.g. card content), not this wrapper.
// Inline style — don't depend on a Tailwind `contents` utility being generated.
style: 'display: contents',
},
})
export class HlmMessageScrollerProvider {}
@Directive({
selector: '[hlmMessageScrollerViewport],hlm-message-scroller-viewport',
hostDirectives: [
{
directive: BrnMessageScrollerViewport,
inputs: ['preserveScrollOnPrepend', 'aria-label', 'tabindex'],
},
],
host: {
'data-slot': 'message-scroller-viewport',
},
})
export class HlmMessageScrollerViewport {
constructor() {
classes(
() =>
'scroll-fade-b size-full min-h-0 min-w-0 scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent',
);
}
}
@Directive({
selector: '[hlmMessageScroller],hlm-message-scroller',
hostDirectives: [{ directive: BrnMessageScrollerRoot }],
host: {
'data-slot': 'message-scroller',
},
})
export class HlmMessageScroller {
constructor() {
classes(() => 'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden');
}
}
export const HlmMessageScrollerImports = [
HlmMessageScrollerProvider,
HlmMessageScroller,
HlmMessageScrollerViewport,
HlmMessageScrollerContent,
HlmMessageScrollerItem,
HlmMessageScrollerButton,
] as const;import { BrnMessageScrollerButton, BrnMessageScrollerContent, BrnMessageScrollerItem, BrnMessageScrollerProvider, BrnMessageScrollerRoot, BrnMessageScrollerViewport } from '@spartan-ng/brain/message-scroller';
import { ChangeDetectionStrategy, Component, Directive, inject } from '@angular/core';
import { HlmButton, provideBrnButtonConfig } from '@spartan-ng/helm/button';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideArrowDown } from '@ng-icons/lucide';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector on native button
selector: 'button[hlmMessageScrollerButton]',
imports: [NgIcon],
providers: [provideIcons({ lucideArrowDown }), provideBrnButtonConfig({ variant: 'secondary', size: 'icon-sm' })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{ directive: HlmButton, inputs: ['variant', 'size'] },
{
directive: BrnMessageScrollerButton,
inputs: ['direction', 'behavior', 'aria-label'],
},
],
host: {
'data-slot': 'message-scroller-button',
},
template: `
<ng-icon name="lucideArrowDown" />
<span class="sr-only">{{ _brn.resolvedAriaLabel() }}</span>
`,
})
export class HlmMessageScrollerButton {
private readonly _button = inject(HlmButton);
protected readonly _brn = inject(BrnMessageScrollerButton);
constructor() {
this._button.setClass(
'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_ng-icon]:rotate-180',
);
}
}
@Directive({
selector: '[hlmMessageScrollerContent],hlm-message-scroller-content',
hostDirectives: [
{
directive: BrnMessageScrollerContent,
inputs: ['aria-relevant', 'spacerClassName'],
},
],
host: {
'data-slot': 'message-scroller-content',
},
})
export class HlmMessageScrollerContent {
constructor() {
classes(() => 'gap-6 flex h-max min-h-full flex-col');
}
}
@Directive({
selector: '[hlmMessageScrollerItem],hlm-message-scroller-item',
hostDirectives: [
{
directive: BrnMessageScrollerItem,
inputs: ['messageId', 'scrollAnchor'],
},
],
host: {
'data-slot': 'message-scroller-item',
},
})
export class HlmMessageScrollerItem {
constructor() {
classes(() => 'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]');
}
}
@Directive({
selector: '[hlmMessageScrollerProvider]',
hostDirectives: [
{
directive: BrnMessageScrollerProvider,
inputs: ['autoScroll', 'defaultScrollPosition', 'scrollEdgeThreshold', 'scrollPreviousItemPeek', 'scrollMargin'],
},
],
host: {
// Match React's Provider (no layout box) so height:100% on the scroller
// resolves against the real parent (e.g. card content), not this wrapper.
// Inline style — don't depend on a Tailwind `contents` utility being generated.
style: 'display: contents',
},
})
export class HlmMessageScrollerProvider {}
@Directive({
selector: '[hlmMessageScrollerViewport],hlm-message-scroller-viewport',
hostDirectives: [
{
directive: BrnMessageScrollerViewport,
inputs: ['preserveScrollOnPrepend', 'aria-label', 'tabindex'],
},
],
host: {
'data-slot': 'message-scroller-viewport',
},
})
export class HlmMessageScrollerViewport {
constructor() {
classes(
() =>
'scroll-fade-b size-full min-h-0 min-w-0 scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent',
);
}
}
@Directive({
selector: '[hlmMessageScroller],hlm-message-scroller',
hostDirectives: [{ directive: BrnMessageScrollerRoot }],
host: {
'data-slot': 'message-scroller',
},
})
export class HlmMessageScroller {
constructor() {
classes(() => 'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden');
}
}
export const HlmMessageScrollerImports = [
HlmMessageScrollerProvider,
HlmMessageScroller,
HlmMessageScrollerViewport,
HlmMessageScrollerContent,
HlmMessageScrollerItem,
HlmMessageScrollerButton,
] as const;import { BrnMessageScrollerButton, BrnMessageScrollerContent, BrnMessageScrollerItem, BrnMessageScrollerProvider, BrnMessageScrollerRoot, BrnMessageScrollerViewport } from '@spartan-ng/brain/message-scroller';
import { ChangeDetectionStrategy, Component, Directive, inject } from '@angular/core';
import { HlmButton, provideBrnButtonConfig } from '@spartan-ng/helm/button';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideArrowDown } from '@ng-icons/lucide';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector on native button
selector: 'button[hlmMessageScrollerButton]',
imports: [NgIcon],
providers: [provideIcons({ lucideArrowDown }), provideBrnButtonConfig({ variant: 'secondary', size: 'icon-sm' })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{ directive: HlmButton, inputs: ['variant', 'size'] },
{
directive: BrnMessageScrollerButton,
inputs: ['direction', 'behavior', 'aria-label'],
},
],
host: {
'data-slot': 'message-scroller-button',
},
template: `
<ng-icon name="lucideArrowDown" />
<span class="sr-only">{{ _brn.resolvedAriaLabel() }}</span>
`,
})
export class HlmMessageScrollerButton {
private readonly _button = inject(HlmButton);
protected readonly _brn = inject(BrnMessageScrollerButton);
constructor() {
this._button.setClass(
'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_ng-icon]:rotate-180',
);
}
}
@Directive({
selector: '[hlmMessageScrollerContent],hlm-message-scroller-content',
hostDirectives: [
{
directive: BrnMessageScrollerContent,
inputs: ['aria-relevant', 'spacerClassName'],
},
],
host: {
'data-slot': 'message-scroller-content',
},
})
export class HlmMessageScrollerContent {
constructor() {
classes(() => 'gap-8 flex h-max min-h-full flex-col');
}
}
@Directive({
selector: '[hlmMessageScrollerItem],hlm-message-scroller-item',
hostDirectives: [
{
directive: BrnMessageScrollerItem,
inputs: ['messageId', 'scrollAnchor'],
},
],
host: {
'data-slot': 'message-scroller-item',
},
})
export class HlmMessageScrollerItem {
constructor() {
classes(() => 'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]');
}
}
@Directive({
selector: '[hlmMessageScrollerProvider]',
hostDirectives: [
{
directive: BrnMessageScrollerProvider,
inputs: ['autoScroll', 'defaultScrollPosition', 'scrollEdgeThreshold', 'scrollPreviousItemPeek', 'scrollMargin'],
},
],
host: {
// Match React's Provider (no layout box) so height:100% on the scroller
// resolves against the real parent (e.g. card content), not this wrapper.
// Inline style — don't depend on a Tailwind `contents` utility being generated.
style: 'display: contents',
},
})
export class HlmMessageScrollerProvider {}
@Directive({
selector: '[hlmMessageScrollerViewport],hlm-message-scroller-viewport',
hostDirectives: [
{
directive: BrnMessageScrollerViewport,
inputs: ['preserveScrollOnPrepend', 'aria-label', 'tabindex'],
},
],
host: {
'data-slot': 'message-scroller-viewport',
},
})
export class HlmMessageScrollerViewport {
constructor() {
classes(
() =>
'scroll-fade-b size-full min-h-0 min-w-0 scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent',
);
}
}
@Directive({
selector: '[hlmMessageScroller],hlm-message-scroller',
hostDirectives: [{ directive: BrnMessageScrollerRoot }],
host: {
'data-slot': 'message-scroller',
},
})
export class HlmMessageScroller {
constructor() {
classes(() => 'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden');
}
}
export const HlmMessageScrollerImports = [
HlmMessageScrollerProvider,
HlmMessageScroller,
HlmMessageScrollerViewport,
HlmMessageScrollerContent,
HlmMessageScrollerItem,
HlmMessageScrollerButton,
] as const;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.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideArrowUp, lucideMessageCircleDashed, lucideRotateCw } from '@ng-icons/lucide';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmEmptyImports } from '@spartan-ng/helm/empty';
import { HlmMessageScrollerImports } from '@spartan-ng/helm/message-scroller';
import { HlmToggleGroupImports } from '@spartan-ng/helm/toggle-group';
import { MessageScrollerTranscriptRow } from './message-scroller-transcript-row';
import { ANCHORING_SCRIPT, type DemoMessage } from './message-scroller.shared';
@Component({
selector: 'spartan-message-scroller-anchoring-preview',
imports: [
HlmMessageScrollerImports,
HlmCardImports,
HlmButtonImports,
HlmEmptyImports,
HlmToggleGroupImports,
MessageScrollerTranscriptRow,
NgIcon,
],
providers: [provideIcons({ lucideArrowUp, lucideMessageCircleDashed, lucideRotateCw })],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'relative flex w-full max-w-sm flex-col gap-4',
},
template: `
<hlm-card class="mx-auto h-[35rem] w-full max-w-sm gap-0">
<hlm-card-header class="border-b">
<h3 hlmCardTitle>Anchoring Turns</h3>
<p hlmCardDescription>Choose which role settles near the top edge.</p>
<div hlmCardAction>
<button
hlmBtn
type="button"
variant="outline"
size="icon"
aria-label="Reset anchored turns"
[disabled]="_messages().length === 0"
(click)="reset()"
>
<ng-icon name="lucideRotateCw" />
</button>
</div>
</hlm-card-header>
<div hlmCardContent class="min-h-0 flex-1 overflow-hidden p-0">
@if (_messages().length === 0) {
<hlm-empty class="h-full">
<hlm-empty-header>
<hlm-empty-media variant="icon">
<ng-icon name="lucideMessageCircleDashed" />
</hlm-empty-media>
<div hlmEmptyTitle>No anchored messages yet</div>
<div hlmEmptyDescription>Send the first message to see the selected role anchor.</div>
</hlm-empty-header>
</hlm-empty>
} @else {
<div hlmMessageScrollerProvider>
<div hlmMessageScroller>
<div hlmMessageScrollerViewport>
<div hlmMessageScrollerContent class="p-(--card-spacing)">
@for (message of _messages(); track message.id) {
<spartan-message-scroller-transcript-row
[messageId]="message.id"
[role]="message.role"
[text]="message.text"
[scrollAnchor]="message.role === _anchorRole()"
userVariant="muted"
assistantVariant="ghost"
[animationClass]="
message.role === 'user'
? 'motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 duration-300'
: ''
"
/>
}
</div>
</div>
<button hlmMessageScrollerButton></button>
</div>
</div>
}
</div>
<hlm-card-footer>
<hlm-toggle-group
type="single"
[spacing]="2"
[value]="_anchorRole()"
(valueChange)="onAnchorRoleChange($event)"
aria-label="Select scroll anchor role"
>
<button hlmToggleGroupItem value="user" aria-label="Anchor user messages">User</button>
<button hlmToggleGroupItem value="assistant" aria-label="Anchor assistant messages">Assistant</button>
</hlm-toggle-group>
<button hlmBtn type="button" size="icon" class="ms-auto" [disabled]="!_nextMessage()" (click)="sendNext()">
<ng-icon name="lucideArrowUp" />
<span class="sr-only">Send Message</span>
</button>
</hlm-card-footer>
</hlm-card>
<div class="text-muted-foreground mx-auto max-w-xs px-0.5 text-center text-xs">
Toggle the anchor role, then send messages to compare where turns settle.
</div>
`,
})
export class MessageScrollerAnchoringPreview {
protected readonly _anchorRole = signal<'user' | 'assistant'>('user');
protected readonly _messages = signal<DemoMessage[]>([]);
protected readonly _messageIndex = signal(0);
protected readonly _nextMessage = () => ANCHORING_SCRIPT[this._messageIndex()] ?? null;
protected onAnchorRoleChange(value: 'user' | 'assistant' | ('user' | 'assistant')[] | null | undefined): void {
const next = Array.isArray(value) ? value[0] : value;
if (next === 'user' || next === 'assistant') {
this._anchorRole.set(next);
}
}
protected reset(): void {
this._messages.set([]);
this._messageIndex.set(0);
}
protected sendNext(): void {
const next = this._nextMessage();
if (!next) {
return;
}
this._messages.update((messages) => [...messages, next]);
this._messageIndex.update((index) => index + 1);
}
}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.
This will create a marker and make it the anchor
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideRotateCw } from '@ng-icons/lucide';
import { HlmBubbleImports } from '@spartan-ng/helm/bubble';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmMarkerImports } from '@spartan-ng/helm/marker';
import { HlmMessageImports } from '@spartan-ng/helm/message';
import { HlmMessageScrollerImports } from '@spartan-ng/helm/message-scroller';
import { HlmTooltipImports } from '@spartan-ng/helm/tooltip';
type GroupChatItem =
| {
id: string;
type: 'event';
text: string;
scrollAnchor?: boolean;
}
| {
id: string;
type: 'message';
sender: string;
role: 'assistant' | 'participant';
text: string;
scrollAnchor?: boolean;
};
const currentUser = 'Grace';
const initialItems: GroupChatItem[] = [
{
id: 'group-1',
type: 'message',
sender: 'Grace',
role: 'participant',
text: '@mary, the astrophage line keeps matching Venus energy output. Can you check my math?',
},
{
id: 'group-2',
type: 'message',
sender: 'Mary (Agent)',
role: 'assistant',
text: '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.',
},
{
id: 'group-3',
type: 'message',
sender: 'Grace',
role: 'participant',
text: 'ping @rocky',
scrollAnchor: true,
},
];
const rockyMarker: GroupChatItem = {
id: 'group-4',
type: 'event',
text: 'Rocky has joined the chat',
scrollAnchor: true,
};
const rockyMessage: GroupChatItem = {
id: 'group-5',
type: 'message',
sender: 'Rocky',
role: 'participant',
text: 'Amaze. Astrophage eats light, makes heat, goes to carbon dioxide. Rocky has fuel model. Grace is smart.',
};
@Component({
selector: 'spartan-message-scroller-group-chat-preview',
imports: [
HlmMessageScrollerImports,
HlmCardImports,
HlmButtonImports,
HlmMessageImports,
HlmBubbleImports,
HlmMarkerImports,
HlmTooltipImports,
NgIcon,
],
providers: [provideIcons({ lucideRotateCw })],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'relative flex w-full max-w-sm flex-col gap-4',
},
template: `
<div hlmMessageScrollerProvider>
<div class="relative flex flex-col gap-4">
<hlm-card class="mx-auto h-[35rem] w-full max-w-sm gap-0">
<hlm-card-header class="gap-1 border-b">
<h3 hlmCardTitle>Group Chat</h3>
<p hlmCardDescription>
A group chat with several participants and an assistant. The Marker is marked as a turn.
</p>
<div hlmCardAction>
<button
hlmBtn
type="button"
variant="outline"
size="icon"
hlmTooltip="Reset"
aria-label="Reset conversation"
[disabled]="_rockyTurn() === 'idle'"
(click)="reset()"
>
<ng-icon name="lucideRotateCw" />
</button>
</div>
</hlm-card-header>
<div hlmCardContent class="min-h-0 flex-1 overflow-hidden p-0">
@for (key of [_demoKey()]; track key) {
<div hlmMessageScroller>
<div hlmMessageScrollerViewport>
<div hlmMessageScrollerContent class="p-(--card-spacing)">
@for (item of _items(); track item.id) {
@if (item.type === 'message') {
<div hlmMessageScrollerItem [messageId]="item.id" [scrollAnchor]="item.scrollAnchor ?? false">
<div hlmMessage [align]="item.sender === _currentUser ? 'end' : 'start'">
<div hlmMessageContent>
@if (item.sender !== _currentUser) {
<div hlmMessageHeader>{{ item.sender }}</div>
}
<div
hlmBubble
[variant]="
item.sender === _currentUser
? 'muted'
: item.role === 'assistant'
? 'ghost'
: 'tinted'
"
>
<div hlmBubbleContent>{{ item.text }}</div>
</div>
</div>
</div>
</div>
} @else {
<div hlmMessageScrollerItem [messageId]="item.id" [scrollAnchor]="item.scrollAnchor ?? false">
<div hlmMarker variant="separator">
<span hlmMarkerContent>{{ item.text }}</span>
</div>
</div>
}
}
</div>
</div>
<button hlmMessageScrollerButton></button>
</div>
}
</div>
<hlm-card-footer class="flex flex-col items-center gap-2 border-t">
<button
hlmBtn
type="button"
class="w-full"
variant="secondary"
[disabled]="_isComplete()"
(click)="advanceRocky()"
>
{{ _buttonLabel() }}
</button>
<p class="text-muted-foreground text-xs">
@if (_rockyTurn() === 'idle') {
This will create a marker and make it the anchor
} @else {
Now send Rocky's reply into the conversation
}
</p>
</hlm-card-footer>
</hlm-card>
<div class="text-muted-foreground mx-auto max-w-sm px-0.5 text-center text-xs text-balance">
When a user joins, a marker is created. scrollAnchor on the marker marks it as the next turn
</div>
</div>
</div>
`,
})
export class MessageScrollerGroupChatPreview {
protected readonly _currentUser = currentUser;
protected readonly _rockyTurn = signal<'idle' | 'marker' | 'message'>('idle');
protected readonly _demoKey = signal(0);
protected readonly _items = computed(() => {
const rockyTurn = this._rockyTurn();
if (rockyTurn === 'message') {
return [...initialItems, rockyMarker, rockyMessage];
}
if (rockyTurn === 'marker') {
return [...initialItems, rockyMarker];
}
return initialItems;
});
protected readonly _buttonLabel = computed(() =>
this._rockyTurn() === 'idle' ? 'Add Rocky' : 'Send Message as Rocky',
);
protected readonly _isComplete = computed(() => this._rockyTurn() === 'message');
protected reset(): void {
this._rockyTurn.set('idle');
this._demoKey.update((key) => key + 1);
}
protected advanceRocky(): void {
this._rockyTurn.update((turn) => (turn === 'idle' ? 'marker' : 'message'));
}
}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.
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideArrowUp,
lucideGlobe,
lucideImage,
lucidePaperclip,
lucidePlus,
lucideRotateCw,
lucideTelescope,
} from '@ng-icons/lucide';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmDropdownMenuImports } from '@spartan-ng/helm/dropdown-menu';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
import { HlmMessageScrollerImports } from '@spartan-ng/helm/message-scroller';
import { HlmSliderImports } from '@spartan-ng/helm/slider';
import { HlmTooltipImports } from '@spartan-ng/helm/tooltip';
import { MessageScrollerTranscriptRow } from './message-scroller-transcript-row';
import { MAIN_CHAT_SCRIPT, ScriptedChatRunner } from './message-scroller.shared';
const DEFAULT_PEEK = 64;
@Component({
selector: 'spartan-message-scroller-previous-context-preview',
imports: [
HlmMessageScrollerImports,
HlmCardImports,
HlmButtonImports,
HlmInputGroupImports,
HlmDropdownMenuImports,
HlmSliderImports,
HlmTooltipImports,
MessageScrollerTranscriptRow,
NgIcon,
],
providers: [
provideIcons({
lucideArrowUp,
lucideGlobe,
lucideImage,
lucidePaperclip,
lucidePlus,
lucideRotateCw,
lucideTelescope,
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'relative flex w-full max-w-sm flex-col gap-4',
},
template: `
@for (key of [_demoKey()]; track key) {
<div hlmMessageScrollerProvider [scrollMargin]="24" [scrollPreviousItemPeek]="_peek()">
<div class="relative flex flex-col gap-4">
<hlm-card class="mx-auto h-[35rem] w-full max-w-sm gap-0">
<hlm-card-header class="gap-1 border-b">
<h3 hlmCardTitle>Keeping Context Visible</h3>
<p hlmCardDescription>New turns keep part of the previous reply in view.</p>
<div hlmCardAction>
<button
hlmBtn
variant="outline"
size="icon"
hlmTooltip="Reset"
aria-label="Reset context example"
[disabled]="_chat.isBusy()"
(click)="reset()"
>
<ng-icon name="lucideRotateCw" />
</button>
</div>
</hlm-card-header>
<div hlmCardContent class="flex-1 overflow-hidden p-0">
<div hlmMessageScroller>
<div hlmMessageScrollerViewport>
<div
hlmMessageScrollerContent
class="p-(--card-spacing)"
[attr.aria-busy]="_chat.isBusy() ? 'true' : null"
>
@for (message of _chat.messages(); track message.id) {
<spartan-message-scroller-transcript-row
[messageId]="message.id"
[role]="message.role"
[text]="message.text"
[scrollAnchor]="message.role === 'user'"
[animationClass]="
message.role === 'user'
? 'motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 duration-300'
: ''
"
/>
}
</div>
</div>
<button hlmMessageScrollerButton></button>
</div>
</div>
<hlm-card-footer class="flex-col gap-2">
<form class="w-full" (submit)="send($event)">
<hlm-input-group>
<div class="h-14 w-full px-3 py-2.5">
<span
class="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
[attr.data-status]="_chat.status()"
>
@if (_nextPreview(); as preview) {
{{ preview }}
} @else {
<span class="text-muted-foreground">No messages queued. Reset the context.</span>
}
</span>
</div>
<hlm-input-group-addon align="block-end" class="pt-1">
<button
hlmInputGroupButton
type="button"
size="icon-sm"
variant="outline"
aria-label="Add files"
[hlmDropdownMenuTrigger]="attachmentMenu"
align="start"
side="top"
>
<ng-icon name="lucidePlus" />
</button>
<div class="flex w-28 items-center gap-2">
<span class="text-muted-foreground text-xs tabular-nums">{{ _peek() }}px</span>
<hlm-slider
aria-label="Previous context peek"
[value]="[_peek()]"
[min]="64"
[max]="128"
[step]="1"
[disabled]="_chat.isBusy()"
(valueChange)="onPeekChange($event)"
/>
</div>
<button
hlmInputGroupButton
type="submit"
variant="default"
size="icon-sm"
class="ms-auto"
[disabled]="!_chat.nextMessage() || _chat.isBusy()"
>
<ng-icon name="lucideArrowUp" />
<span class="sr-only">Send</span>
</button>
</hlm-input-group-addon>
</hlm-input-group>
</form>
</hlm-card-footer>
</hlm-card>
<div class="text-muted-foreground px-0.5 text-center text-xs">
Adjust the slider and send. Observe the previous message peek
</div>
</div>
</div>
}
<ng-template #attachmentMenu>
<hlm-dropdown-menu class="w-44">
<button hlmDropdownMenuItem>
<ng-icon name="lucidePaperclip" />
Add Photos & Files
</button>
<div hlmDropdownMenuSeparator></div>
<button hlmDropdownMenuItem>
<ng-icon name="lucideImage" />
Create Image
</button>
<button hlmDropdownMenuItem>
<ng-icon name="lucideTelescope" />
Deep Research
</button>
<button hlmDropdownMenuItem>
<ng-icon name="lucideGlobe" />
Web Search
</button>
</hlm-dropdown-menu>
</ng-template>
`,
})
export class MessageScrollerPreviousContextPreview {
protected readonly _peek = signal(DEFAULT_PEEK);
protected readonly _demoKey = signal(0);
protected readonly _chat = new ScriptedChatRunner(MAIN_CHAT_SCRIPT, {
streamDelayMs: 35,
thinkDelayMs: 1000,
initialCount: 2,
});
protected readonly _nextPreview = computed(() => this._chat.nextMessage()?.text ?? null);
protected onPeekChange(value: number[]): void {
this._peek.set(value[0] ?? DEFAULT_PEEK);
}
protected reset(): void {
this._chat.reset(2);
this._peek.set(DEFAULT_PEEK);
this._demoKey.update((key) => key + 1);
}
protected send(event: Event): void {
event.preventDefault();
if (!this._chat.nextMessage() || this._chat.isBusy()) {
return;
}
void this._chat.sendNext();
}
}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.
autoScroll is enabled. import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideArrowUp,
lucideGlobe,
lucideImage,
lucideMessageCircleDashed,
lucidePaperclip,
lucidePlus,
lucideRotateCw,
lucideTelescope,
} from '@ng-icons/lucide';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmDropdownMenuImports } from '@spartan-ng/helm/dropdown-menu';
import { HlmEmptyImports } from '@spartan-ng/helm/empty';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
import { HlmMessageScrollerImports } from '@spartan-ng/helm/message-scroller';
import { HlmTooltipImports } from '@spartan-ng/helm/tooltip';
import { MessageScrollerTranscriptRow } from './message-scroller-transcript-row';
import { MAIN_CHAT_SCRIPT, ScriptedChatRunner } from './message-scroller.shared';
@Component({
selector: 'spartan-message-scroller-streaming-preview',
imports: [
HlmMessageScrollerImports,
HlmCardImports,
HlmButtonImports,
HlmEmptyImports,
HlmInputGroupImports,
HlmDropdownMenuImports,
HlmTooltipImports,
MessageScrollerTranscriptRow,
NgIcon,
],
providers: [
provideIcons({
lucideArrowUp,
lucideGlobe,
lucideImage,
lucideMessageCircleDashed,
lucidePaperclip,
lucidePlus,
lucideRotateCw,
lucideTelescope,
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'relative flex w-full max-w-sm flex-col gap-4',
},
template: `
<div hlmMessageScrollerProvider autoScroll>
<hlm-card class="mx-auto h-[35rem] w-full max-w-sm gap-0">
<hlm-card-header class="gap-1 border-b">
<h3 hlmCardTitle>Streaming Messages</h3>
<p hlmCardDescription>Auto-scroll follows the live edge of the conversation.</p>
<div hlmCardAction>
<button
hlmBtn
variant="outline"
size="icon"
hlmTooltip="Reset"
aria-label="Reset stream"
[disabled]="_chat.messages().length === 0 || _chat.isBusy()"
(click)="reset()"
>
<ng-icon name="lucideRotateCw" />
</button>
</div>
</hlm-card-header>
<div hlmCardContent class="min-h-0 flex-1 overflow-hidden p-0">
@if (_chat.messages().length === 0) {
<hlm-empty class="h-full">
<hlm-empty-header>
<hlm-empty-media variant="icon">
<ng-icon name="lucideMessageCircleDashed" />
</hlm-empty-media>
<div hlmEmptyTitle>Ready to Stream</div>
<div hlmEmptyDescription>Press send to stream a scripted launch summary.</div>
</hlm-empty-header>
</hlm-empty>
} @else {
<div hlmMessageScroller>
<div hlmMessageScrollerViewport>
<div
hlmMessageScrollerContent
class="p-(--card-spacing)"
[attr.aria-busy]="_chat.isBusy() ? 'true' : null"
>
@for (message of _chat.messages(); track message.id) {
<spartan-message-scroller-transcript-row
[messageId]="message.id"
[role]="message.role"
[text]="message.text"
[scrollAnchor]="message.role === 'user'"
[animationClass]="
message.role === 'user'
? 'motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 duration-300'
: ''
"
/>
}
</div>
</div>
<button hlmMessageScrollerButton></button>
</div>
}
</div>
<hlm-card-footer class="flex-col gap-2">
<form class="w-full" (submit)="send($event)">
<hlm-input-group>
<div class="h-14 w-full px-3 py-2.5">
<span
class="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
[attr.data-status]="_chat.status()"
>
@if (_nextPreview(); as preview) {
{{ preview }}
} @else {
<span class="text-muted-foreground">No messages queued. Reset the stream.</span>
}
</span>
</div>
<hlm-input-group-addon align="block-end" class="pt-1">
<button
hlmInputGroupButton
type="button"
size="icon-sm"
variant="outline"
aria-label="Add files"
[hlmDropdownMenuTrigger]="attachmentMenu"
align="start"
>
<ng-icon name="lucidePlus" />
</button>
<button
hlmInputGroupButton
type="submit"
variant="default"
size="icon-sm"
class="ms-auto"
[disabled]="!_chat.nextMessage() || _chat.isBusy()"
>
<ng-icon name="lucideArrowUp" />
<span class="sr-only">Send</span>
</button>
</hlm-input-group-addon>
</hlm-input-group>
</form>
</hlm-card-footer>
</hlm-card>
<div class="text-muted-foreground px-0.5 text-center text-xs">
Streaming is simulated.
<code class="text-xs">autoScroll</code>
is enabled.
</div>
</div>
<ng-template #attachmentMenu>
<hlm-dropdown-menu class="w-44">
<button hlmDropdownMenuItem>
<ng-icon name="lucidePaperclip" />
Add Photos & Files
</button>
<div hlmDropdownMenuSeparator></div>
<button hlmDropdownMenuItem>
<ng-icon name="lucideImage" />
Create Image
</button>
<button hlmDropdownMenuItem>
<ng-icon name="lucideTelescope" />
Deep Research
</button>
<button hlmDropdownMenuItem>
<ng-icon name="lucideGlobe" />
Web Search
</button>
</hlm-dropdown-menu>
</ng-template>
`,
})
export class MessageScrollerStreamingPreview {
protected readonly _chat = new ScriptedChatRunner(MAIN_CHAT_SCRIPT, {
streamDelayMs: 20,
thinkDelayMs: 1000,
});
protected readonly _nextPreview = computed(() => this._chat.nextMessage()?.text ?? null);
protected reset(): void {
this._chat.reset();
}
protected send(event: Event): void {
event.preventDefault();
if (!this._chat.nextMessage() || this._chat.isBusy()) {
return;
}
void this._chat.sendNext();
}
}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.
import { ChangeDetectionStrategy, Component, effect, input, signal } from '@angular/core';
import { injectBrnMessageScroller } from '@spartan-ng/brain/message-scroller';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmMessageScrollerImports } from '@spartan-ng/helm/message-scroller';
import { HlmTabsImports } from '@spartan-ng/helm/tabs';
import { MessageScrollerTranscriptRow } from './message-scroller-transcript-row';
import { OPENING_POSITION_SCRIPT } from './message-scroller.shared';
@Component({
selector: 'spartan-message-scroller-opening-position-scroller',
imports: [HlmMessageScrollerImports, MessageScrollerTranscriptRow],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div hlmMessageScroller>
<div hlmMessageScrollerViewport>
<div hlmMessageScrollerContent class="p-(--card-spacing)">
@for (message of _messages; track message.id) {
<spartan-message-scroller-transcript-row
[messageId]="message.id"
[role]="message.role"
[text]="message.text"
[scrollAnchor]="message.role === 'user'"
userVariant="muted"
assistantVariant="ghost"
/>
}
</div>
</div>
<button hlmMessageScrollerButton></button>
</div>
`,
})
export class MessageScrollerOpeningPositionScroller {
private readonly _scroller = injectBrnMessageScroller();
protected readonly _messages = OPENING_POSITION_SCRIPT;
public readonly position = input.required<'start' | 'end' | 'last-anchor'>();
public readonly positionKey = input.required<number>();
constructor() {
effect((onCleanup) => {
const position = this.position();
void this.positionKey();
if (typeof window === 'undefined') {
return;
}
// Match shadcn's opening-position demo: drive start/end/last-anchor via
// commands after layout, not defaultScrollPosition (which can fall back to
// end when it thinks the last turn fits).
const frame = requestAnimationFrame(() => {
if (position === 'start') {
this._scroller.scrollToStart({ behavior: 'auto' });
return;
}
if (position === 'end') {
this._scroller.scrollToEnd({ behavior: 'auto' });
return;
}
this._scroller.scrollToMessage('open-3', {
align: 'start',
behavior: 'auto',
scrollMargin: 64,
});
});
onCleanup(() => cancelAnimationFrame(frame));
});
}
}
@Component({
selector: 'spartan-message-scroller-opening-position-preview',
imports: [HlmCardImports, HlmTabsImports, HlmMessageScrollerImports, MessageScrollerOpeningPositionScroller],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'relative flex w-full max-w-sm flex-col gap-4',
},
template: `
<hlm-card class="mx-auto h-[35rem] w-full max-w-sm gap-0">
<hlm-card-header class="gap-1 border-b">
<h3 hlmCardTitle>Opening Position</h3>
<p hlmCardDescription>Choose where a saved transcript opens.</p>
</hlm-card-header>
<div hlmCardContent class="min-h-0 flex-1 overflow-hidden p-0">
<div hlmMessageScrollerProvider>
<spartan-message-scroller-opening-position-scroller [position]="_position()" [positionKey]="_positionKey()" />
</div>
</div>
<hlm-card-footer class="flex items-center justify-center border-t">
<hlm-tabs [tab]="_position()" class="w-full" (tabActivated)="onPositionChange($event)">
<hlm-tabs-list class="w-full">
<button hlmTabsTrigger="start">start</button>
<button hlmTabsTrigger="end">end</button>
<button hlmTabsTrigger="last-anchor">last-anchor</button>
</hlm-tabs-list>
</hlm-tabs>
</hlm-card-footer>
</hlm-card>
<div class="text-muted-foreground mx-auto max-w-sm px-0.5 text-center text-xs">
Toggle the defaultScrollPosition to see where the transcript starts when you open the thread
</div>
`,
})
export class MessageScrollerOpeningPositionPreview {
protected readonly _position = signal<'start' | 'end' | 'last-anchor'>('last-anchor');
protected readonly _positionKey = signal(0);
protected onPositionChange(value: string): void {
if (value === 'start' || value === 'end' || value === 'last-anchor') {
this._position.set(value);
this._positionKey.update((key) => key + 1);
}
}
}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.
Restore earlier messages while keeping your place.
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideRotateCw } from '@ng-icons/lucide';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmMarkerImports } from '@spartan-ng/helm/marker';
import { HlmMessageScrollerImports } from '@spartan-ng/helm/message-scroller';
import { HlmTooltipImports } from '@spartan-ng/helm/tooltip';
import { MessageScrollerTranscriptRow } from './message-scroller-transcript-row';
import { HISTORY_SCRIPT } from './message-scroller.shared';
const INITIAL_VISIBLE_COUNT = 5;
@Component({
selector: 'spartan-message-scroller-load-history-preview',
imports: [
HlmMessageScrollerImports,
HlmCardImports,
HlmButtonImports,
HlmMarkerImports,
HlmTooltipImports,
MessageScrollerTranscriptRow,
NgIcon,
],
providers: [provideIcons({ lucideRotateCw })],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'relative flex w-full max-w-sm flex-col gap-4',
},
template: `
<div hlmMessageScrollerProvider>
<div class="relative flex flex-col gap-4">
<hlm-card class="mx-auto h-[35rem] w-full max-w-sm gap-0">
<hlm-card-header class="gap-1 border-b">
<h3 hlmCardTitle>Load History</h3>
<p hlmCardDescription>Prepended messages keep your place.</p>
<div hlmCardAction>
<button
hlmBtn
type="button"
variant="outline"
size="icon"
hlmTooltip="Reset"
aria-label="Reset loaded messages"
[disabled]="_visibleCount() === _initialVisibleCount"
(click)="reset()"
>
<ng-icon name="lucideRotateCw" />
</button>
</div>
</hlm-card-header>
<div hlmCardContent class="flex-1 overflow-hidden p-0">
@for (key of [_demoKey()]; track key) {
<div hlmMessageScroller>
<div hlmMessageScrollerViewport>
<div hlmMessageScrollerContent class="p-(--card-spacing)">
@for (message of _visibleMessages(); track message.id) {
<spartan-message-scroller-transcript-row
[messageId]="message.id"
[role]="message.role"
[text]="message.text"
userVariant="muted"
assistantVariant="ghost"
/>
}
<div hlmMessageScrollerItem [scrollAnchor]="false">
<div hlmMarker variant="separator">
<span hlmMarkerContent>End of Conversation</span>
</div>
</div>
</div>
</div>
<button hlmMessageScrollerButton></button>
</div>
}
</div>
<hlm-card-footer class="flex flex-col items-center gap-2 border-t">
<button
hlmBtn
type="button"
class="w-full"
variant="secondary"
[disabled]="!_canLoadHistory()"
(click)="loadHistory()"
>
{{ _canLoadHistory() ? 'Load History' : 'History Loaded' }}
</button>
<p class="text-muted-foreground text-xs">Restore earlier messages while keeping your place.</p>
</hlm-card-footer>
</hlm-card>
</div>
</div>
<div class="text-muted-foreground mx-auto max-w-sm px-0.5 text-center text-xs text-balance">
Click Load History to load the entire conversation
</div>
`,
})
export class MessageScrollerLoadHistoryPreview {
protected readonly _initialVisibleCount = INITIAL_VISIBLE_COUNT;
protected readonly _demoKey = signal(0);
protected readonly _visibleCount = signal(INITIAL_VISIBLE_COUNT);
protected readonly _visibleMessages = computed(() => HISTORY_SCRIPT.slice(-this._visibleCount()));
protected readonly _canLoadHistory = computed(() => this._visibleCount() < HISTORY_SCRIPT.length);
protected reset(): void {
this._visibleCount.set(INITIAL_VISIBLE_COUNT);
this._demoKey.update((key) => key + 1);
}
protected loadHistory(): void {
if (!this._canLoadHistory()) {
return;
}
this._visibleCount.set(HISTORY_SCRIPT.length);
toast('History loaded', {
description: 'Scroll up to see earlier messages.',
});
}
}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.
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideArrowUp, lucideMessageCircleDashed, lucideRotateCw } from '@ng-icons/lucide';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmEmptyImports } from '@spartan-ng/helm/empty';
import { HlmMessageScrollerImports } from '@spartan-ng/helm/message-scroller';
import { HlmSelectImports } from '@spartan-ng/helm/select';
import { MessageScrollerTranscriptRow } from './message-scroller-transcript-row';
import { ANIMATION_SCRIPT, ScriptedChatRunner } from './message-scroller.shared';
type AnimationPreset = {
id: string;
name: string;
className: string;
};
const ANIMATION_PRESETS: AnimationPreset[] = [
{
id: 'fade',
name: 'Fade',
className: 'motion-safe:animate-in motion-safe:fade-in duration-200',
},
{
id: 'slide-up',
name: 'Slide Up',
className: 'motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 duration-300',
},
{
id: 'slide-side',
name: 'Slide Side',
className: 'motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-right-4 duration-300',
},
{
id: 'pop',
name: 'Pop',
className:
'motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-95 motion-safe:slide-in-from-bottom-2 origin-bottom-right duration-300',
},
{
id: 'spring-bounce',
name: 'Spring Bounce',
className:
'motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-95 motion-safe:slide-in-from-bottom-3 duration-500',
},
{
id: 'blur-fade',
name: 'Blur Fade',
className: 'motion-safe:animate-in motion-safe:fade-in motion-safe:blur-in duration-300',
},
{
id: 'scale-fade',
name: 'Scale Fade',
className: 'motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-95 duration-300',
},
];
@Component({
selector: 'spartan-message-scroller-animation-preview',
imports: [
HlmMessageScrollerImports,
HlmCardImports,
HlmButtonImports,
HlmEmptyImports,
HlmSelectImports,
MessageScrollerTranscriptRow,
NgIcon,
],
providers: [provideIcons({ lucideArrowUp, lucideMessageCircleDashed, lucideRotateCw })],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'relative flex w-full max-w-sm flex-col gap-4',
},
template: `
<div class="relative flex flex-col gap-4">
<hlm-card class="mx-auto h-[35rem] w-full max-w-sm gap-0">
<hlm-card-header class="border-b">
<h3 hlmCardTitle>Animation</h3>
<p hlmCardDescription>Choose how user messages are animated when they are added to the conversation.</p>
<div hlmCardAction class="flex items-center gap-2">
<button
hlmBtn
type="button"
variant="outline"
size="icon"
aria-label="Reset animated messages"
[disabled]="_chat.messages().length === 0 || _chat.isBusy()"
(click)="reset()"
>
<ng-icon name="lucideRotateCw" />
</button>
</div>
</hlm-card-header>
<div hlmCardContent class="min-h-0 flex-1 overflow-hidden p-0">
@if (_chat.messages().length === 0) {
<hlm-empty class="h-full">
<hlm-empty-header>
<hlm-empty-media variant="icon">
<ng-icon name="lucideMessageCircleDashed" />
</hlm-empty-media>
<div hlmEmptyTitle>No Messages Yet</div>
<div hlmEmptyDescription>Click the button below to send the first message.</div>
</hlm-empty-header>
</hlm-empty>
} @else {
<div hlmMessageScrollerProvider>
<div hlmMessageScroller>
<div hlmMessageScrollerViewport>
<div
hlmMessageScrollerContent
class="p-(--card-spacing)"
[attr.aria-busy]="_chat.isBusy() ? 'true' : null"
>
@for (message of _chat.messages(); track message.id) {
<spartan-message-scroller-transcript-row
[messageId]="message.id"
[role]="message.role"
[text]="message.text"
[scrollAnchor]="message.role === 'user'"
userVariant="muted"
assistantVariant="ghost"
[animationClass]="message.role === 'user' ? _selectedPreset().className : ''"
/>
}
</div>
</div>
<button hlmMessageScrollerButton></button>
</div>
</div>
}
</div>
<hlm-card-footer class="border-t">
<hlm-select [value]="_presetId()" (valueChange)="onPresetChange($event)" [itemToString]="_presetToString">
<hlm-select-trigger aria-label="Animation preset">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal>
@for (preset of _presets; track preset.id) {
<hlm-select-item [value]="preset.id">{{ preset.name }}</hlm-select-item>
}
</hlm-select-content>
</hlm-select>
<button
hlmBtn
type="button"
size="icon"
class="ms-auto"
[disabled]="!_chat.nextMessage() || _chat.isBusy()"
(click)="sendNext()"
>
<ng-icon name="lucideArrowUp" />
<span class="sr-only">Send Message</span>
</button>
</hlm-card-footer>
</hlm-card>
</div>
<div class="text-muted-foreground mx-auto max-w-sm px-0.5 text-center text-xs text-balance">
Select an animation then click send to see it in action.
</div>
`,
})
export class MessageScrollerAnimationPreview {
protected readonly _presets = ANIMATION_PRESETS;
protected readonly _presetId = signal(ANIMATION_PRESETS[0].id);
protected readonly _chat = new ScriptedChatRunner(ANIMATION_SCRIPT, {
streamDelayMs: 15,
thinkDelayMs: 1000,
});
protected readonly _selectedPreset = computed(
() => this._presets.find((preset) => preset.id === this._presetId()) ?? this._presets[0],
);
protected readonly _presetToString = (value: string | null | undefined) =>
this._presets.find((preset) => preset.id === value)?.name ?? '';
protected onPresetChange(value: string | null | undefined): void {
if (value) {
this._presetId.set(value);
}
}
protected reset(): void {
this._chat.reset();
}
protected sendNext(): void {
if (!this._chat.nextMessage() || this._chat.isBusy()) {
return;
}
void this._chat.sendNext();
}
}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.
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { injectBrnMessageScroller } from '@spartan-ng/brain/message-scroller';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmDropdownMenuImports } from '@spartan-ng/helm/dropdown-menu';
import { HlmMessageScrollerImports } from '@spartan-ng/helm/message-scroller';
import { MessageScrollerTranscriptRow } from './message-scroller-transcript-row';
import { COMMANDS_SCRIPT, trimMessageText } from './message-scroller.shared';
@Component({
selector: 'spartan-message-scroller-commands-menu',
imports: [HlmButtonImports, HlmDropdownMenuImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button hlmBtn type="button" variant="secondary" [hlmDropdownMenuTrigger]="menu" align="end" side="bottom">
Jump to...
</button>
<ng-template #menu>
<hlm-dropdown-menu class="w-64">
<div hlmDropdownMenuGroup>
<div hlmDropdownMenuLabel>Conversations</div>
@for (message of _userMessages(); track message.id) {
<button hlmDropdownMenuItem (click)="jumpTo(message.id)">
<span class="line-clamp-1 min-w-0">{{ _trimMessageText(message.text) }}</span>
</button>
}
</div>
</hlm-dropdown-menu>
</ng-template>
`,
})
export class MessageScrollerCommandsMenu {
private readonly _scroller = injectBrnMessageScroller();
protected readonly _userMessages = computed(() => COMMANDS_SCRIPT.filter((message) => message.role === 'user'));
protected readonly _trimMessageText = trimMessageText;
protected jumpTo(messageId: string): void {
this._scroller.scrollToMessage(messageId, {
align: 'start',
behavior: 'smooth',
});
}
}
@Component({
selector: 'spartan-message-scroller-commands-preview',
imports: [HlmMessageScrollerImports, HlmCardImports, MessageScrollerTranscriptRow, MessageScrollerCommandsMenu],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'relative flex w-full max-w-sm flex-col gap-4',
},
template: `
<div hlmMessageScrollerProvider defaultScrollPosition="end">
<div class="relative flex flex-col gap-4">
<hlm-card class="mx-auto h-[35rem] w-full max-w-sm gap-0">
<hlm-card-header class="gap-1 border-b">
<h3 hlmCardTitle>Commands</h3>
<p hlmCardDescription>Drive the transcript from outside.</p>
<div hlmCardAction>
<spartan-message-scroller-commands-menu />
</div>
</hlm-card-header>
<div hlmCardContent class="flex-1 overflow-hidden p-0">
<div hlmMessageScroller>
<div hlmMessageScrollerViewport>
<div hlmMessageScrollerContent class="p-(--card-spacing)">
@for (message of _messages; track message.id) {
<spartan-message-scroller-transcript-row
[messageId]="message.id"
[role]="message.role"
[text]="message.text"
[scrollAnchor]="message.role === 'user'"
userVariant="muted"
assistantVariant="ghost"
/>
}
</div>
</div>
<button hlmMessageScrollerButton></button>
</div>
</div>
</hlm-card>
</div>
</div>
<div class="text-muted-foreground mx-auto max-w-sm px-0.5 text-center text-xs text-balance">
Use the controls to jump to any message in the conversation.
</div>
`,
})
export class MessageScrollerCommandsPreview {
protected readonly _messages = COMMANDS_SCRIPT;
}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.
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject } from '@angular/core';
import { injectBrnMessageScroller } from '@spartan-ng/brain/message-scroller';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmHoverCardImports } from '@spartan-ng/helm/hover-card';
import { HlmMessageScrollerImports } from '@spartan-ng/helm/message-scroller';
import { MessageScrollerTranscriptRow } from './message-scroller-transcript-row';
import { VISIBILITY_SCRIPT, trimMessageText } from './message-scroller.shared';
@Component({
selector: 'spartan-message-scroller-transcript-outline',
imports: [HlmHoverCardImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-hover-card>
<button
type="button"
hlmHoverCardTrigger
align="left"
[sideOffset]="-28"
aria-label="Open transcript outline"
class="focus-visible:ring-ring/50 flex h-9 w-9 flex-col items-center justify-center gap-1 rounded-md transition-colors outline-none focus-visible:ring-3"
>
@for (message of _userMessages(); track message.id) {
<span
class="bg-muted-foreground/40 data-[current=true]:bg-foreground h-0.5 w-4 rounded-full"
[attr.data-current]="message.id === _currentAnchorId()"
></span>
}
</button>
<hlm-hover-card-content *hlmHoverCardPortal class="flex w-64 flex-col gap-1 rounded-2xl p-1">
@for (message of _userMessages(); track message.id) {
<button
type="button"
class="hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-[current=location]:bg-accent aria-[current=location]:text-accent-foreground flex min-h-7 items-center rounded-xl px-2 py-1.5 text-left text-sm transition-colors outline-none"
[attr.aria-current]="message.id === _currentAnchorId() ? 'location' : null"
(click)="jumpTo(message.id)"
>
<span class="line-clamp-1 min-w-0">{{ _trimMessageText(message.text) }}</span>
</button>
}
</hlm-hover-card-content>
</hlm-hover-card>
`,
})
export class MessageScrollerTranscriptOutline {
private readonly _scroller = injectBrnMessageScroller();
private readonly _destroyRef = inject(DestroyRef);
protected readonly _userMessages = computed(() => VISIBILITY_SCRIPT.filter((message) => message.role === 'user'));
protected readonly _currentAnchorId = computed(() => this._scroller.visibility().currentAnchorId);
protected readonly _trimMessageText = trimMessageText;
constructor() {
this._scroller.observeVisibility();
this._destroyRef.onDestroy(() => this._scroller.unobserveVisibility());
}
protected jumpTo(messageId: string): void {
this._scroller.scrollToMessage(messageId, {
align: 'start',
behavior: 'smooth',
});
}
}
@Component({
selector: 'spartan-message-scroller-visibility-preview',
imports: [HlmMessageScrollerImports, HlmCardImports, MessageScrollerTranscriptRow, MessageScrollerTranscriptOutline],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'relative flex w-full flex-col gap-4 pe-12',
},
template: `
<div hlmMessageScrollerProvider [scrollMargin]="12">
<div class="relative mx-auto w-full max-w-sm">
<hlm-card class="h-[35rem] w-full gap-0">
<hlm-card-header class="gap-1 border-b">
<h3 hlmCardTitle>Transcript Outline</h3>
<p hlmCardDescription>Track the current anchored turn.</p>
</hlm-card-header>
<div hlmCardContent class="min-h-0 flex-1 overflow-hidden p-0">
<div hlmMessageScroller>
<div hlmMessageScrollerViewport>
<div hlmMessageScrollerContent class="p-(--card-spacing)">
@for (message of _messages; track message.id) {
<spartan-message-scroller-transcript-row
[messageId]="message.id"
[role]="message.role"
[text]="message.text"
[scrollAnchor]="message.role === 'user'"
userVariant="muted"
assistantVariant="ghost"
/>
}
</div>
</div>
<button hlmMessageScrollerButton></button>
</div>
</div>
</hlm-card>
<div class="absolute -end-12 top-1/2 -translate-y-1/2">
<spartan-message-scroller-transcript-outline />
</div>
</div>
</div>
<div class="text-muted-foreground mx-auto max-w-sm px-0.5 text-center text-xs">
Open the outline to jump between anchored turns as you read.
</div>
`,
})
export class MessageScrollerVisibilityPreview {
protected readonly _messages = VISIBILITY_SCRIPT;
}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.
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { injectBrnMessageScroller } from '@spartan-ng/brain/message-scroller';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmMessageScrollerImports } from '@spartan-ng/helm/message-scroller';
import { MessageScrollerTranscriptRow } from './message-scroller-transcript-row';
import { createScrollableScript } from './message-scroller.shared';
function getScrollStatus({ start, end }: { start: boolean; end: boolean }): string {
if (start && end) {
return 'You can scroll both ways.';
}
if (end) {
return 'You are at the top. You can only scroll down.';
}
if (start) {
return 'You are at the bottom. You can only scroll up.';
}
return 'All messages fit in the viewport.';
}
@Component({
selector: 'spartan-message-scroller-scrollable-footer',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
{{ _status() }}
`,
})
export class MessageScrollerScrollableFooter {
private readonly _scroller = injectBrnMessageScroller();
protected readonly _status = computed(() => getScrollStatus(this._scroller.scrollable()));
}
@Component({
selector: 'spartan-message-scroller-scrollable-preview',
imports: [HlmMessageScrollerImports, HlmCardImports, MessageScrollerTranscriptRow, MessageScrollerScrollableFooter],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'mx-auto flex w-full max-w-sm flex-col gap-4',
},
template: `
<hlm-card class="h-[35rem] w-full gap-0 overflow-hidden">
<hlm-card-header class="gap-1 border-b">
<h3 hlmCardTitle>Scroll Status</h3>
<p hlmCardDescription>Where the reader can go scroll to based on current scroll position.</p>
</hlm-card-header>
<div hlmMessageScrollerProvider defaultScrollPosition="start">
<div hlmCardContent class="flex-1 overflow-hidden p-0">
<div hlmMessageScroller>
<div hlmMessageScrollerViewport>
<div hlmMessageScrollerContent class="gap-4 p-(--card-spacing)">
@for (message of _messages; track message.id) {
<spartan-message-scroller-transcript-row
[messageId]="message.id"
[role]="message.role"
[text]="message.text"
[scrollAnchor]="message.role === 'user'"
userVariant="muted"
assistantVariant="ghost"
/>
}
</div>
</div>
<button hlmMessageScrollerButton></button>
</div>
</div>
<hlm-card-footer class="text-muted-foreground justify-center border-t text-center text-sm">
<spartan-message-scroller-scrollable-footer />
</hlm-card-footer>
</div>
</hlm-card>
<div class="text-muted-foreground px-0.5 text-center text-xs">Scroll the transcript to see the footer update.</div>
`,
})
export class MessageScrollerScrollablePreview {
protected readonly _messages = createScrollableScript();
}Brain API
BrnMessageScrollerButton
Selector: button[brnMessageScrollerButton]
ExportAs: brnMessageScrollerButton
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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
On This Page