ZaidanHomeDocsComponentsBlocksChartsTypesetCreate

Command Palette

Search for a command to run...

GitHub
New
Sections
  • Introduction
  • Components
  • Blocks
  • Installation
  • Customization
  • Dark Mode
  • Typeset
  • Zaidan Skills
  • FAQ
  • Roadmap
  • Changelog
Installation
  • Astro
  • Manual
  • Solid Start
  • TanStack Router
  • TanStack Start
  • Vite
Blocks
  • Data Grid
  • Event Calendar
  • Filters
  • Gantt
  • Image Crop
  • Kanban
  • Message Scroller
  • Questionnaire
  • Sortable
Components
  • Accordion
  • Alert
  • Alert Dialog
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Label
  • Marker
  • Menubar
  • Message
  • Native Select
  • Navigation Menu
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Resizable
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Table
  • Tabs
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip

Message Scroller

New Chat
How can I help you today?
Morning, zaidan!
What are we working on today? Press send to start a new conversation
I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.
Demo is read only. Press send to send messages.
1
import {
2
ArrowUpIcon,
3
GlobeIcon,
4
ImageIcon,
5
MessageCircleDashedIcon,
6
PaperclipIcon,
7
PlusIcon,
8
RotateCwIcon,
9
TelescopeIcon,
10
} from "lucide-solid";
11
import { For, Show } from "solid-js";
12
import { MessageScroller } from "@/registry/kobalte/blocks/message-scroller";
13
import { Button } from "~/components/ui/button";
14
import {
15
Card,
16
CardAction,
17
CardContent,
18
CardDescription,
19
CardFooter,
20
CardHeader,
21
CardTitle,
22
} from "~/components/ui/card";
23
import {
24
DropdownMenu,
25
DropdownMenuContent,
26
DropdownMenuItem,
27
DropdownMenuSeparator,
28
DropdownMenuTrigger,
29
} from "~/components/ui/dropdown-menu";
30
import {
31
Empty,
32
EmptyDescription,
33
EmptyHeader,
34
EmptyMedia,
35
EmptyTitle,
36
} from "~/components/ui/empty";
37
import { InputGroup, InputGroupAddon, InputGroupButton } from "~/components/ui/input-group";
38
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
39
import {
40
createScriptedChat,
41
MessageAnimated,
42
scrollBehaviorScript,
43
} from "./message-scroller-utils";
44
45
export default function MessageScrollerDemo() {
46
const chat = createScriptedChat({ delayMs: 20, script: scrollBehaviorScript });
47
48
return (
49
<MessageScroller.Provider>
50
<div class="relative flex flex-col gap-4">
51
<Card class="mx-auto h-140 w-full max-w-sm gap-0">
52
<CardHeader class="gap-1 border-b">
53
<CardTitle>New Chat</CardTitle>
54
<CardDescription>How can I help you today?</CardDescription>
55
<CardAction>
56
<Tooltip>
57
<TooltipTrigger as="span" class="inline-block w-fit">
58
<Button
59
variant="outline"
60
size="icon"
61
aria-label="Reset conversation"
62
disabled={chat.messages.length === 0 || chat.isBusy()}
63
onClick={chat.reset}
64
>
65
<RotateCwIcon />
66
</Button>
67
</TooltipTrigger>
68
<TooltipContent>
69
<p>Reset</p>
70
</TooltipContent>
71
</Tooltip>
72
</CardAction>
73
</CardHeader>
74
<CardContent class="min-h-0 flex-1 overflow-hidden p-0">
75
<Show
76
when={chat.messages.length > 0}
77
fallback={
78
<Empty class="h-full">
79
<EmptyHeader>
80
<EmptyMedia variant="icon">
81
<MessageCircleDashedIcon />
82
</EmptyMedia>
83
<EmptyTitle>Morning, zaidan!</EmptyTitle>
84
<EmptyDescription>
85
What are we working on today? Press send to start a new conversation
86
</EmptyDescription>
87
</EmptyHeader>
88
</Empty>
89
}
90
>
91
<MessageScroller.Root>
92
<MessageScroller.Viewport>
93
<MessageScroller.Content aria-busy={chat.isBusy()} class="p-(--card-spacing)">
94
<For each={chat.messages}>
95
{(message) => <MessageAnimated message={message} />}
96
</For>
97
</MessageScroller.Content>
98
</MessageScroller.Viewport>
99
<MessageScroller.Button />
100
</MessageScroller.Root>
101
</Show>
102
</CardContent>
103
<CardFooter class="flex-col gap-2">
104
<form
105
class="w-full"
106
onSubmit={(event) => {
107
event.preventDefault();
108
chat.send();
109
}}
110
>
111
<InputGroup>
112
<div class="h-14 w-full px-3 py-2.5">
113
<span
114
class="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
115
data-status={chat.status()}
116
>
117
<Show
118
when={chat.nextMessage()}
119
keyed
120
fallback={
121
<span class="text-muted-foreground">
122
No messages queued. Reset the conversation.
123
</span>
124
}
125
>
126
{(message) => message.text}
127
</Show>
128
</span>
129
</div>
130
<InputGroupAddon align="block-end" class="pt-1">
131
<DropdownMenu placement="top-start">
132
<DropdownMenuTrigger
133
as={InputGroupButton}
134
aria-label="Add files"
135
type="button"
136
size="icon-sm"
137
variant="outline"
138
>
139
<PlusIcon />
140
</DropdownMenuTrigger>
141
<DropdownMenuContent class="w-44">
142
<DropdownMenuItem>
143
<PaperclipIcon />
144
Add Photos & Files
145
</DropdownMenuItem>
146
<DropdownMenuSeparator />
147
<DropdownMenuItem>
148
<ImageIcon />
149
Create Image
150
</DropdownMenuItem>
151
<DropdownMenuItem>
152
<TelescopeIcon />
153
Deep Research
154
</DropdownMenuItem>
155
<DropdownMenuItem>
156
<GlobeIcon />
157
Web Search
158
</DropdownMenuItem>
159
</DropdownMenuContent>
160
</DropdownMenu>
161
<InputGroupButton
162
type="submit"
163
variant="default"
164
size="icon-sm"
165
disabled={!chat.nextMessage() || chat.isBusy()}
166
class="ml-auto"
167
>
168
<ArrowUpIcon />
169
<span class="sr-only">Send</span>
170
</InputGroupButton>
171
</InputGroupAddon>
172
</InputGroup>
173
</form>
174
</CardFooter>
175
</Card>
176
<div class="px-0.5 text-center text-muted-foreground text-xs">
177
Demo is read only. Press send to send messages.
178
</div>
179
</div>
180
</MessageScroller.Provider>
181
);
182
}

MessageScroller is a Solid chat transcript scroller for saved conversations and live responses. It keeps the reader in control: streaming output follows only at the live edge, appending a turn can preserve nearby context, and prepended history does not displace the visible row.

What Makes a Great Streaming Chat Experience

Streaming changes a simple message list into a scroll-state problem. New content should arrive without taking readers away from a selection, an older reply, a keyboard interaction, or a link they are following.

  1. Move only when the reader has chosen to follow.
  2. Keep following while the reader stays at the live edge.
  3. Treat scrolling, keyboard navigation, and direct jumps as reader intent.
  4. Start a new turn near the top of the viewport, with a little previous context still visible.
  5. Let off-screen content arrive quietly, and make it easy to jump back to it.
  6. Reopen a saved transcript at a meaningful turn rather than always at its final pixel.
  7. Keep the visible row stable while history, images, or rich content change layout.
  8. Keep the transcript navigable without noisy announcements.

Never move the reader against their intent.

MessageScroller

MessageScroller is a chat transcript scroller built for these behaviors. MessageScroller.Provider owns the scroll state and transcript-row behavior: opening position, streamed output, new-turn anchoring, prepended history, visibility, and scroll controls. MessageScroller.Root is the styled frame that renders inside it.

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

It gives you the scroll behavior that chat needs, without taking over the rest of the chat UI. And it stays fast, even in long conversations with rich markdown.

Installation

pnpm dlx shadcn@latest add @zaidan/message-scroller
npx shadcn@latest add @zaidan/message-scroller
yarn dlx shadcn@latest add @zaidan/message-scroller
bunx --bun shadcn@latest add @zaidan/message-scroller

Usage

1
import { For } from "solid-js";
2
import { MessageScroller } from "~/components/blocks/message-scroller";
1
<MessageScroller.Provider autoScroll>
2
<MessageScroller.Root>
3
<MessageScroller.Viewport>
4
<MessageScroller.Content>
5
<For each={messages}>
6
{(message) => (
7
<MessageScroller.Item
8
messageId={message.id}
9
scrollAnchor={message.role === "user"}
10
>
11
<Bubble>Message content</Bubble>
12
</MessageScroller.Item>
13
)}
14
</For>
15
</MessageScroller.Content>
16
</MessageScroller.Viewport>
17
<MessageScroller.Button />
18
</MessageScroller.Root>
19
</MessageScroller.Provider>

MessageScroller.Root fills its parent, so place it in a height-constrained layout.

<div class="flex h-screen flex-col">
<MessageScroller.Provider>
<MessageScroller.Root class="flex-1">{/* transcript */}</MessageScroller.Root>
</MessageScroller.Provider>
</div>

Composition

MessageScroller.Provider
└── MessageScroller.Root
├── MessageScroller.Viewport
│ └── MessageScroller.Content
│ └── MessageScroller.Item
└── MessageScroller.Button
  • MessageScroller.Provider is the headless root. It owns opening position, follow-output, anchoring, scroll commands, and visibility state.
  • MessageScroller.Root is the styled frame.
  • MessageScroller.Viewport receives native scroll input and preserves the visible row when history is prepended.
  • MessageScroller.Content is the transcript container and defaults to a live log.
  • MessageScroller.Item wraps each direct row so it can be measured, anchored, preserved, tracked, and addressed by messageId.
  • MessageScroller.Button is an inert scroll control until content exists in its direction.

Core Concepts

Anchoring Turns

Mark the row that starts a meaningful exchange with scrollAnchor. It can be a user prompt, a system marker, or a group-chat event; it is not tied to a role. A newly appended anchor settles near the top of the viewport and leaves room for its response below.

Anchoring Turns
Choose which role settles near the top edge.
No anchored messages yet
Send the first message to see the selected role anchor.
Toggle the anchor role, then send messages to compare where turns settle.
1
import { ArrowUpIcon, MessageCircleDashedIcon, RotateCwIcon } from "lucide-solid";
2
import { createSignal, For, Show } from "solid-js";
3
import { MessageScroller } from "@/registry/kobalte/blocks/message-scroller";
4
import { Button } from "~/components/ui/button";
5
import {
6
Card,
7
CardAction,
8
CardContent,
9
CardDescription,
10
CardFooter,
11
CardHeader,
12
CardTitle,
13
} from "~/components/ui/card";
14
import {
15
Empty,
16
EmptyDescription,
17
EmptyHeader,
18
EmptyMedia,
19
EmptyTitle,
20
} from "~/components/ui/empty";
21
import { ToggleGroup, ToggleGroupItem } from "~/components/ui/toggle-group";
22
import { createScript, type DemoMessage, MessageAnimated } from "./message-scroller-utils";
23
24
type AnchorRole = DemoMessage["role"];
25
26
const anchorScript = createScript("anchor", [
27
{
28
question: "Can you show me how anchoring behaves when a new prompt starts the turn?",
29
answer:
30
"Append the user prompt first, then append the assistant response. With User selected, the prompt settles near the top and the assistant response fills in below it.",
31
},
32
{
33
question: "What changes when assistant messages are the anchor?",
34
answer:
35
"Now each assistant response is the item `MessageScroller` keeps in view. This is useful when the reply is the moment you want readers to land on after each turn.",
36
},
37
{
38
question: "Can I switch roles and keep adding turns?",
39
answer:
40
"Yes. The next appended message with the selected role becomes the anchor, so you can compare user and assistant anchoring without resetting the demo.",
41
},
42
]);
43
44
export default function MessageScrollerAnchoring() {
45
const [anchorRole, setAnchorRole] = createSignal<AnchorRole>("user");
46
const [messages, setMessages] = createSignal<DemoMessage[]>([]);
47
const [messageIndex, setMessageIndex] = createSignal(0);
48
const nextMessage = () => anchorScript.messages[messageIndex()];
49
50
const reset = () => {
51
setMessages([]);
52
setMessageIndex(0);
53
};
54
55
return (
56
<div class="relative flex flex-col gap-4">
57
<Card class="mx-auto h-140 w-full max-w-sm gap-0">
58
<CardHeader class="border-b">
59
<CardTitle>Anchoring Turns</CardTitle>
60
<CardDescription>Choose which role settles near the top edge.</CardDescription>
61
<CardAction>
62
<Button
63
variant="outline"
64
size="icon"
65
aria-label="Reset anchored turns"
66
disabled={messages().length === 0}
67
onClick={reset}
68
>
69
<RotateCwIcon />
70
</Button>
71
</CardAction>
72
</CardHeader>
73
<CardContent class="min-h-0 flex-1 overflow-hidden p-0">
74
<Show
75
when={messages().length > 0}
76
fallback={
77
<Empty class="h-full">
78
<EmptyHeader>
79
<EmptyMedia variant="icon">
80
<MessageCircleDashedIcon />
81
</EmptyMedia>
82
<EmptyTitle>No anchored messages yet</EmptyTitle>
83
<EmptyDescription>
84
Send the first message to see the selected role anchor.
85
</EmptyDescription>
86
</EmptyHeader>
87
</Empty>
88
}
89
>
90
<MessageScroller.Provider>
91
<MessageScroller.Root>
92
<MessageScroller.Viewport>
93
<MessageScroller.Content class="p-(--card-spacing)">
94
<For each={messages()}>
95
{(message) => (
96
<MessageAnimated
97
message={message}
98
scrollAnchor={message.role === anchorRole()}
99
userVariant="muted"
100
assistantVariant="ghost"
101
/>
102
)}
103
</For>
104
</MessageScroller.Content>
105
</MessageScroller.Viewport>
106
<MessageScroller.Button />
107
</MessageScroller.Root>
108
</MessageScroller.Provider>
109
</Show>
110
</CardContent>
111
<CardFooter>
112
<ToggleGroup
113
aria-label="Select scroll anchor role"
114
value={anchorRole()}
115
onChange={(value) => {
116
if (value === "user" || value === "assistant") {
117
setAnchorRole(value);
118
reset();
119
}
120
}}
121
>
122
<ToggleGroupItem value="user" aria-label="Anchor user messages">
123
User
124
</ToggleGroupItem>
125
<ToggleGroupItem value="assistant" aria-label="Anchor assistant messages">
126
Assistant
127
</ToggleGroupItem>
128
</ToggleGroup>
129
<Button
130
size="icon"
131
class="ml-auto"
132
disabled={!nextMessage()}
133
onClick={() => {
134
const message = nextMessage();
135
if (!message) return;
136
137
setMessages((current) => [...current, message]);
138
setMessageIndex((index) => index + 1);
139
}}
140
>
141
<ArrowUpIcon />
142
<span class="sr-only">Send Message</span>
143
</Button>
144
</CardFooter>
145
</Card>
146
<div class="mx-auto max-w-xs px-0.5 text-center text-muted-foreground text-xs">
147
Toggle the anchor role, then send messages to compare where turns settle.
148
</div>
149
</div>
150
);
151
}

Group Chat

Group-chat turn boundaries are often markers rather than a participant message. Put the marker inside its own item and set scrollAnchor on that item.

<MessageScroller.Item messageId="marcus-joined" scrollAnchor>
<p>Marcus joined the chat</p>
</MessageScroller.Item>
Group Chat
A group chat with several participants and an assistant. The Marker is marked as a turn.
@mary, the astrophage line keeps matching Venus energy output. Can you check my math?
Mary (Agent)
Yes. Confirmed. The curve points to a microorganism harvesting stellar energy and breeding near carbon dioxide. If @rocky agrees, this is the clue we need.
ping @rocky

This will create a marker and make it the anchor

When a user joins, a marker is created. scrollAnchor on the marker marks it as the next turn
1
import { RotateCwIcon } from "lucide-solid";
2
import { createSignal, For, Show } from "solid-js";
3
import { MessageScroller } from "@/registry/kobalte/blocks/message-scroller";
4
import { Bubble, BubbleContent } from "~/components/ui/bubble";
5
import { Button } from "~/components/ui/button";
6
import {
7
Card,
8
CardAction,
9
CardContent,
10
CardDescription,
11
CardFooter,
12
CardHeader,
13
CardTitle,
14
} from "~/components/ui/card";
15
import { Marker, MarkerContent } from "~/components/ui/marker";
16
import { Message, MessageContent, MessageHeader } from "~/components/ui/message";
17
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
18
import type { BubbleVariant } from "./message-scroller-utils";
19
20
type GroupChatItem =
21
| {
22
id: string;
23
type: "event";
24
text: string;
25
scrollAnchor?: boolean;
26
}
27
| {
28
id: string;
29
type: "message";
30
sender: string;
31
role: "assistant" | "participant";
32
text: string;
33
scrollAnchor?: boolean;
34
};
35
36
const currentUser = "Grace";
37
38
const initialItems = [
39
{
40
id: "group-1",
41
type: "message",
42
sender: "Grace",
43
role: "participant",
44
text: "@mary, the astrophage line keeps matching Venus energy output. Can you check my math?",
45
},
46
{
47
id: "group-2",
48
type: "message",
49
sender: "Mary (Agent)",
50
role: "assistant",
51
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.",
52
},
53
{
54
id: "group-3",
55
type: "message",
56
sender: "Grace",
57
role: "participant",
58
text: "ping @rocky",
59
scrollAnchor: true,
60
},
61
] satisfies GroupChatItem[];
62
63
const rockyMarker = {
64
id: "group-4",
65
type: "event",
66
text: "Rocky has joined the chat",
67
scrollAnchor: true,
68
} satisfies GroupChatItem;
69
70
const rockyMessage = {
71
id: "group-5",
72
type: "message",
73
sender: "Rocky",
74
role: "participant",
75
text: "Amaze. Astrophage eats light, makes heat, goes to carbon dioxide. Rocky has fuel model. Grace is smart.",
76
} satisfies GroupChatItem;
77
78
type RockyTurn = "idle" | "marker" | "message";
79
80
export default function MessageScrollerGroupChat() {
81
// Solid has no `key` prop; bumping this value re-creates the keyed `Show`
82
// subtree below, which is how the upstream demo resets scroller state.
83
const [demoKey, setDemoKey] = createSignal(1);
84
const [rockyTurn, setRockyTurn] = createSignal<RockyTurn>("idle");
85
const items = (): GroupChatItem[] => {
86
if (rockyTurn() === "message") return [...initialItems, rockyMarker, rockyMessage];
87
if (rockyTurn() === "marker") return [...initialItems, rockyMarker];
88
return initialItems;
89
};
90
const buttonLabel = () => (rockyTurn() === "idle" ? "Add Rocky" : "Send Message as Rocky");
91
const isComplete = () => rockyTurn() === "message";
92
93
return (
94
<MessageScroller.Provider>
95
<div class="relative flex flex-col gap-4">
96
<Card class="mx-auto h-140 w-full max-w-sm gap-0">
97
<CardHeader class="gap-1 border-b">
98
<CardTitle>Group Chat</CardTitle>
99
<CardDescription>
100
A group chat with several participants and an assistant. The Marker is marked as a
101
turn.
102
</CardDescription>
103
<CardAction>
104
<Tooltip>
105
<TooltipTrigger as="span" class="inline-block w-fit">
106
<Button
107
type="button"
108
variant="outline"
109
size="icon"
110
aria-label="Reset conversation"
111
disabled={rockyTurn() === "idle"}
112
onClick={() => {
113
setRockyTurn("idle");
114
setDemoKey((key) => key + 1);
115
}}
116
>
117
<RotateCwIcon />
118
</Button>
119
</TooltipTrigger>
120
<TooltipContent>
121
<p>Reset</p>
122
</TooltipContent>
123
</Tooltip>
124
</CardAction>
125
</CardHeader>
126
<CardContent class="min-h-0 flex-1 overflow-hidden p-0">
127
<Show when={demoKey()} keyed>
128
<MessageScroller.Root>
129
<MessageScroller.Viewport>
130
<MessageScroller.Content class="p-(--card-spacing)">
131
<For each={items()}>
132
{(item) =>
133
item.type === "message" ? (
134
<GroupChatMessage item={item} />
135
) : (
136
<GroupChatMarker item={item} scrollAnchor={item.scrollAnchor} />
137
)
138
}
139
</For>
140
</MessageScroller.Content>
141
</MessageScroller.Viewport>
142
<MessageScroller.Button />
143
</MessageScroller.Root>
144
</Show>
145
</CardContent>
146
<CardFooter class="flex flex-col items-center gap-2 border-t">
147
<Button
148
type="button"
149
disabled={isComplete()}
150
onClick={() => setRockyTurn((turn) => (turn === "idle" ? "marker" : "message"))}
151
class="w-full"
152
variant="secondary"
153
>
154
{buttonLabel()}
155
</Button>
156
<p class="text-muted-foreground text-xs">
157
{rockyTurn() === "idle"
158
? "This will create a marker and make it the anchor"
159
: "Now send Rocky's reply into the conversation"}
160
</p>
161
</CardFooter>
162
</Card>
163
<div class="mx-auto max-w-sm px-0.5 text-balance text-center text-muted-foreground text-xs">
164
When a user joins, a marker is created. scrollAnchor on the marker marks it as the next
165
turn
166
</div>
167
</div>
168
</MessageScroller.Provider>
169
);
170
}
171
172
function GroupChatMessage(props: { item: Extract<GroupChatItem, { type: "message" }> }) {
173
const isCurrentUser = () => props.item.sender === currentUser;
174
const variant = (): BubbleVariant => {
175
if (isCurrentUser()) return "muted";
176
return props.item.role === "assistant" ? "ghost" : "tinted";
177
};
178
179
return (
180
<MessageScroller.Item messageId={props.item.id} scrollAnchor={props.item.scrollAnchor}>
181
<Message align={isCurrentUser() ? "end" : "start"}>
182
<MessageContent>
183
<Show when={!isCurrentUser()}>
184
<MessageHeader>{props.item.sender}</MessageHeader>
185
</Show>
186
<Bubble variant={variant()}>
187
<BubbleContent>{props.item.text}</BubbleContent>
188
</Bubble>
189
</MessageContent>
190
</Message>
191
</MessageScroller.Item>
192
);
193
}
194
195
function GroupChatMarker(props: {
196
item: Extract<GroupChatItem, { type: "event" }>;
197
scrollAnchor?: boolean;
198
}) {
199
return (
200
<MessageScroller.Item scrollAnchor={props.scrollAnchor ?? false}>
201
<Marker variant="separator">
202
<MarkerContent>{props.item.text}</MarkerContent>
203
</Marker>
204
</MessageScroller.Item>
205
);
206
}

Keeping Context Visible

scrollPreviousItemPeek keeps a slice of the prior item above a newly anchored row. This makes a new turn feel connected to the conversation that led into it.

Keeping Context Visible
New turns keep part of the previous reply in view.

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

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

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

Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.
64px
Adjust the slider and send. Observe the previous message peak
1
import {
2
ArrowUpIcon,
3
GlobeIcon,
4
ImageIcon,
5
PaperclipIcon,
6
PlusIcon,
7
RotateCwIcon,
8
TelescopeIcon,
9
} from "lucide-solid";
10
import { createSignal, For, Show } from "solid-js";
11
import { MessageScroller } from "@/registry/kobalte/blocks/message-scroller";
12
import { Button } from "~/components/ui/button";
13
import {
14
Card,
15
CardAction,
16
CardContent,
17
CardDescription,
18
CardFooter,
19
CardHeader,
20
CardTitle,
21
} from "~/components/ui/card";
22
import {
23
DropdownMenu,
24
DropdownMenuContent,
25
DropdownMenuItem,
26
DropdownMenuSeparator,
27
DropdownMenuTrigger,
28
} from "~/components/ui/dropdown-menu";
29
import { InputGroup, InputGroupAddon, InputGroupButton } from "~/components/ui/input-group";
30
import { Slider } from "~/components/ui/slider";
31
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
32
import {
33
createScriptedChat,
34
MessageAnimated,
35
scrollBehaviorScript,
36
} from "./message-scroller-utils";
37
38
const DEFAULT_PEEK = 64;
39
40
export default function MessageScrollerPreviousContext() {
41
const [peek, setPeek] = createSignal(DEFAULT_PEEK);
42
// One turn is already on screen so the peek band has something to preserve.
43
const chat = createScriptedChat({
44
delayMs: 35,
45
initialCount: 2,
46
script: scrollBehaviorScript,
47
});
48
49
return (
50
<MessageScroller.Provider scrollMargin={24} scrollPreviousItemPeek={peek()}>
51
<div class="relative flex flex-col gap-4">
52
<Card class="mx-auto h-140 w-full max-w-sm gap-0">
53
<CardHeader class="gap-1 border-b">
54
<CardTitle>Keeping Context Visible</CardTitle>
55
<CardDescription>New turns keep part of the previous reply in view.</CardDescription>
56
<CardAction>
57
<Tooltip>
58
<TooltipTrigger as="span" class="inline-block w-fit">
59
<Button
60
variant="outline"
61
size="icon"
62
aria-label="Reset context example"
63
disabled={chat.isBusy()}
64
onClick={() => {
65
chat.reset();
66
setPeek(DEFAULT_PEEK);
67
}}
68
>
69
<RotateCwIcon />
70
</Button>
71
</TooltipTrigger>
72
<TooltipContent>
73
<p>Reset</p>
74
</TooltipContent>
75
</Tooltip>
76
</CardAction>
77
</CardHeader>
78
<CardContent class="min-h-0 flex-1 overflow-hidden p-0">
79
<MessageScroller.Root>
80
<MessageScroller.Viewport>
81
<MessageScroller.Content aria-busy={chat.isBusy()} class="p-(--card-spacing)">
82
<For each={chat.messages}>
83
{(message) => (
84
<MessageAnimated message={message} scrollAnchor={message.role === "user"} />
85
)}
86
</For>
87
</MessageScroller.Content>
88
</MessageScroller.Viewport>
89
<MessageScroller.Button />
90
</MessageScroller.Root>
91
</CardContent>
92
<CardFooter class="flex-col gap-2">
93
<form
94
class="w-full"
95
onSubmit={(event) => {
96
event.preventDefault();
97
chat.send();
98
}}
99
>
100
<InputGroup>
101
<div class="h-14 w-full px-3 py-2.5">
102
<span
103
class="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
104
data-status={chat.status()}
105
>
106
<Show
107
when={chat.nextMessage()}
108
keyed
109
fallback={
110
<span class="text-muted-foreground">
111
No messages queued. Reset the context.
112
</span>
113
}
114
>
115
{(message) => message.text}
116
</Show>
117
</span>
118
</div>
119
<InputGroupAddon align="block-end" class="pt-1">
120
<DropdownMenu placement="top-start">
121
<DropdownMenuTrigger
122
as={InputGroupButton}
123
aria-label="Add files"
124
type="button"
125
size="icon-sm"
126
variant="outline"
127
>
128
<PlusIcon />
129
</DropdownMenuTrigger>
130
<DropdownMenuContent class="w-44">
131
<DropdownMenuItem>
132
<PaperclipIcon />
133
Add Photos & Files
134
</DropdownMenuItem>
135
<DropdownMenuSeparator />
136
<DropdownMenuItem>
137
<ImageIcon />
138
Create Image
139
</DropdownMenuItem>
140
<DropdownMenuItem>
141
<TelescopeIcon />
142
Deep Research
143
</DropdownMenuItem>
144
<DropdownMenuItem>
145
<GlobeIcon />
146
Web Search
147
</DropdownMenuItem>
148
</DropdownMenuContent>
149
</DropdownMenu>
150
<div class="flex w-28 items-center gap-2">
151
<span class="text-muted-foreground text-xs tabular-nums">{peek()}px</span>
152
<Slider
153
aria-label="Previous context peek"
154
value={[peek()]}
155
minValue={64}
156
maxValue={128}
157
step={1}
158
disabled={chat.isBusy()}
159
onChange={(value) => setPeek(value[0] ?? DEFAULT_PEEK)}
160
/>
161
</div>
162
<InputGroupButton
163
type="submit"
164
variant="default"
165
size="icon-sm"
166
disabled={!chat.nextMessage() || chat.isBusy()}
167
class="ml-auto"
168
>
169
<ArrowUpIcon />
170
<span class="sr-only">Send</span>
171
</InputGroupButton>
172
</InputGroupAddon>
173
</InputGroup>
174
</form>
175
</CardFooter>
176
</Card>
177
<div class="px-0.5 text-center text-muted-foreground text-xs">
178
Adjust the slider and send. Observe the previous message peak
179
</div>
180
</div>
181
</MessageScroller.Provider>
182
);
183
}

Following the Live Edge

With autoScroll, streamed output remains visible only while the reader is already at the live edge. Scrolling with a wheel, touch, keyboard, scrollbar, or a direct message jump releases the view. MessageScroller.Button returns to the latest reply and re-engages follow-output.

Streaming Messages
Auto-scroll follows the live edge of the conversation.
Ready to Stream
Press send to stream a scripted launch summary.
I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.
Streaming is simulated. `autoScroll` is enabled.
1
import {
2
ArrowUpIcon,
3
GlobeIcon,
4
ImageIcon,
5
MessageCircleDashedIcon,
6
PaperclipIcon,
7
PlusIcon,
8
RotateCwIcon,
9
TelescopeIcon,
10
} from "lucide-solid";
11
import { For, Show } from "solid-js";
12
import { MessageScroller } from "@/registry/kobalte/blocks/message-scroller";
13
import { Button } from "~/components/ui/button";
14
import {
15
Card,
16
CardAction,
17
CardContent,
18
CardDescription,
19
CardFooter,
20
CardHeader,
21
CardTitle,
22
} from "~/components/ui/card";
23
import {
24
DropdownMenu,
25
DropdownMenuContent,
26
DropdownMenuItem,
27
DropdownMenuSeparator,
28
DropdownMenuTrigger,
29
} from "~/components/ui/dropdown-menu";
30
import {
31
Empty,
32
EmptyDescription,
33
EmptyHeader,
34
EmptyMedia,
35
EmptyTitle,
36
} from "~/components/ui/empty";
37
import { InputGroup, InputGroupAddon, InputGroupButton } from "~/components/ui/input-group";
38
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
39
import {
40
createScriptedChat,
41
MessageAnimated,
42
scrollBehaviorScript,
43
} from "./message-scroller-utils";
44
45
export default function MessageScrollerStreaming() {
46
const chat = createScriptedChat({
47
delayMs: 20,
48
initialCount: 0,
49
script: scrollBehaviorScript,
50
});
51
52
return (
53
<MessageScroller.Provider autoScroll>
54
<div class="relative flex flex-col gap-4">
55
<Card class="mx-auto h-140 w-full max-w-sm gap-0">
56
<CardHeader class="gap-1 border-b">
57
<CardTitle>Streaming Messages</CardTitle>
58
<CardDescription>
59
Auto-scroll follows the live edge of the conversation.
60
</CardDescription>
61
<CardAction>
62
<Tooltip>
63
<TooltipTrigger as="span" class="inline-block w-fit">
64
<Button
65
variant="outline"
66
size="icon"
67
aria-label="Reset stream"
68
disabled={chat.messages.length === 0 || chat.isBusy()}
69
onClick={chat.reset}
70
>
71
<RotateCwIcon />
72
</Button>
73
</TooltipTrigger>
74
<TooltipContent>
75
<p>Reset</p>
76
</TooltipContent>
77
</Tooltip>
78
</CardAction>
79
</CardHeader>
80
<CardContent class="min-h-0 flex-1 overflow-hidden p-0">
81
<Show
82
when={chat.messages.length > 0}
83
fallback={
84
<Empty class="h-full">
85
<EmptyHeader>
86
<EmptyMedia variant="icon">
87
<MessageCircleDashedIcon />
88
</EmptyMedia>
89
<EmptyTitle>Ready to Stream</EmptyTitle>
90
<EmptyDescription>
91
Press send to stream a scripted launch summary.
92
</EmptyDescription>
93
</EmptyHeader>
94
</Empty>
95
}
96
>
97
<MessageScroller.Root>
98
<MessageScroller.Viewport>
99
<MessageScroller.Content aria-busy={chat.isBusy()} class="p-(--card-spacing)">
100
<For each={chat.messages}>
101
{(message) => (
102
<MessageAnimated message={message} scrollAnchor={message.role === "user"} />
103
)}
104
</For>
105
</MessageScroller.Content>
106
</MessageScroller.Viewport>
107
<MessageScroller.Button />
108
</MessageScroller.Root>
109
</Show>
110
</CardContent>
111
<CardFooter class="flex-col gap-2">
112
<form
113
class="w-full"
114
onSubmit={(event) => {
115
event.preventDefault();
116
chat.send();
117
}}
118
>
119
<InputGroup>
120
<div class="h-14 w-full px-3 py-2.5">
121
<span
122
class="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
123
data-status={chat.status()}
124
>
125
<Show
126
when={chat.nextMessage()}
127
keyed
128
fallback={
129
<span class="text-muted-foreground">
130
No messages queued. Reset the stream.
131
</span>
132
}
133
>
134
{(message) => message.text}
135
</Show>
136
</span>
137
</div>
138
<InputGroupAddon align="block-end" class="pt-1">
139
<DropdownMenu placement="top-start">
140
<DropdownMenuTrigger
141
as={InputGroupButton}
142
aria-label="Add files"
143
type="button"
144
size="icon-sm"
145
variant="outline"
146
>
147
<PlusIcon />
148
</DropdownMenuTrigger>
149
<DropdownMenuContent class="w-44">
150
<DropdownMenuItem>
151
<PaperclipIcon />
152
Add Photos & Files
153
</DropdownMenuItem>
154
<DropdownMenuSeparator />
155
<DropdownMenuItem>
156
<ImageIcon />
157
Create Image
158
</DropdownMenuItem>
159
<DropdownMenuItem>
160
<TelescopeIcon />
161
Deep Research
162
</DropdownMenuItem>
163
<DropdownMenuItem>
164
<GlobeIcon />
165
Web Search
166
</DropdownMenuItem>
167
</DropdownMenuContent>
168
</DropdownMenu>
169
<InputGroupButton
170
type="submit"
171
variant="default"
172
size="icon-sm"
173
disabled={!chat.nextMessage() || chat.isBusy()}
174
class="ml-auto"
175
>
176
<ArrowUpIcon />
177
<span class="sr-only">Send</span>
178
</InputGroupButton>
179
</InputGroupAddon>
180
</InputGroup>
181
</form>
182
</CardFooter>
183
</Card>
184
<div class="px-0.5 text-center text-muted-foreground text-xs">
185
Streaming is simulated. `autoScroll` is enabled.
186
</div>
187
</div>
188
</MessageScroller.Provider>
189
);
190
}

Calling scrollToEnd, or pressing MessageScroller.Button, re-engages follow-output when autoScroll is enabled. The root and viewport expose data-autoscrolling while that programmatic scroll to the latest message runs, so you can apply styles during the transition.

Opening Saved Threads

Use defaultScrollPosition="last-anchor" to reopen at the last meaningful turn. It falls back to the end when no anchor exists or the final anchored turn already fits in view. Use "start" or "end" when those positions better match the product.

Opening Position
Choose where a saved transcript opens.

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

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

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

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

Recommended follow-up:

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

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

Toggle the defaultScrollPosition to see where the transcript starts when you open the thread
1
import { createEffect, createSignal, For, onCleanup, Show } from "solid-js";
2
import { MessageScroller, useMessageScroller } from "@/registry/kobalte/blocks/message-scroller";
3
import { Bubble, BubbleContent } from "~/components/ui/bubble";
4
import {
5
Card,
6
CardContent,
7
CardDescription,
8
CardFooter,
9
CardHeader,
10
CardTitle,
11
} from "~/components/ui/card";
12
import { Message, MessageContent } from "~/components/ui/message";
13
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
14
import { type DemoMessage, splitParagraphs } from "./message-scroller-utils";
15
16
type Position = "end" | "last-anchor" | "start";
17
18
const messages: DemoMessage[] = [
19
{
20
id: "open-1",
21
role: "user",
22
text: "This is the first message the user sent in the conversation.",
23
},
24
{
25
id: "open-2",
26
role: "assistant",
27
text: "Workspace creation rose 8%, but first invite completion only rose 2%.",
28
},
29
{
30
id: "open-3",
31
role: "user",
32
text: "This is the last message the user sent in the conversation.",
33
},
34
{
35
id: "open-4",
36
role: "assistant",
37
text: "Start with the invite step. Teams are creating workspaces but waiting to add collaborators.\n\nRecommended follow-up:\n\n1. Compare invite drop-off by account size.\n2. Check whether users who skip invites still return within 24 hours.\n3. Review the empty-state copy on the first project screen.\n4. Segment activation by template, since template users may not need invites right away.\n\nIf that pattern holds, the next experiment should make collaboration useful earlier instead of prompting for invites harder.",
38
},
39
];
40
41
const positions: { label: string; value: Position }[] = [
42
{ label: "start", value: "start" },
43
{ label: "end", value: "end" },
44
{ label: "last-anchor", value: "last-anchor" },
45
];
46
47
export default function MessageScrollerOpeningPosition() {
48
const [position, setPosition] = createSignal<Position>("last-anchor");
49
50
return (
51
<div class="relative flex flex-col gap-4">
52
<Card class="mx-auto h-140 w-full max-w-sm gap-0">
53
<CardHeader class="gap-1 border-b">
54
<CardTitle>Opening Position</CardTitle>
55
<CardDescription>Choose where a saved transcript opens.</CardDescription>
56
</CardHeader>
57
<CardContent class="min-h-0 flex-1 overflow-hidden p-0">
58
<MessageScroller.Provider>
59
{/* Keyed so switching tabs recreates the scroller and genuinely
60
re-opens the thread at the new position. */}
61
<Show when={position()} keyed>
62
{(current) => <OpeningPositionScroller position={current} />}
63
</Show>
64
</MessageScroller.Provider>
65
</CardContent>
66
<CardFooter class="flex items-center justify-center border-t">
67
<Tabs
68
value={position()}
69
onChange={(value) => setPosition(value as Position)}
70
class="w-full"
71
>
72
<TabsList class="w-full">
73
<For each={positions}>
74
{(option) => <TabsTrigger value={option.value}>{option.label}</TabsTrigger>}
75
</For>
76
</TabsList>
77
</Tabs>
78
</CardFooter>
79
</Card>
80
<div class="mx-auto max-w-sm px-0.5 text-center text-muted-foreground text-xs">
81
Toggle the defaultScrollPosition to see where the transcript starts when you open the thread
82
</div>
83
</div>
84
);
85
}
86
87
function OpeningPositionScroller(props: { position: Position }) {
88
const { scrollToEnd, scrollToMessage, scrollToStart } = useMessageScroller();
89
90
createEffect(() => {
91
const position = props.position;
92
const frame = window.requestAnimationFrame(() => {
93
if (position === "start") {
94
scrollToStart({ behavior: "auto" });
95
return;
96
}
97
98
if (position === "end") {
99
scrollToEnd({ behavior: "auto" });
100
return;
101
}
102
103
scrollToMessage("open-3", { align: "start", behavior: "auto", scrollMargin: 64 });
104
});
105
106
onCleanup(() => window.cancelAnimationFrame(frame));
107
});
108
109
return (
110
<MessageScroller.Root>
111
<MessageScroller.Viewport>
112
<MessageScroller.Content class="p-(--card-spacing)">
113
<For each={messages}>
114
{(message) => {
115
const isUser = message.role === "user";
116
117
return (
118
<MessageScroller.Item messageId={message.id} scrollAnchor={isUser}>
119
<Message align={isUser ? "end" : "start"}>
120
<MessageContent>
121
<Bubble variant={isUser ? "muted" : "ghost"}>
122
<BubbleContent class="space-y-2">
123
<For each={splitParagraphs(message.text)}>
124
{(paragraph) => <p class="whitespace-pre-wrap">{paragraph}</p>}
125
</For>
126
</BubbleContent>
127
</Bubble>
128
</MessageContent>
129
</Message>
130
</MessageScroller.Item>
131
);
132
}}
133
</For>
134
</MessageScroller.Content>
135
</MessageScroller.Viewport>
136
<MessageScroller.Button />
137
</MessageScroller.Root>
138
);
139
}

Loading Earlier Messages

preserveScrollOnPrepend is enabled by default on the viewport. Give rows stable messageId values so the component can preserve a specific visible row when older messages are loaded above it.

Load History
Prepended messages keep your place.

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

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

Do we need to roll back?

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

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

Keep watching for customer-visible issues.

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

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

End of Conversation

Restore earlier messages while keeping your place.

Click Load History to load the entire conversation
1
import { RotateCwIcon } from "lucide-solid";
2
import { createSignal, For, Show } from "solid-js";
3
import { MessageScroller } from "@/registry/kobalte/blocks/message-scroller";
4
import { Bubble, BubbleContent } from "~/components/ui/bubble";
5
import { Button } from "~/components/ui/button";
6
import {
7
Card,
8
CardAction,
9
CardContent,
10
CardDescription,
11
CardFooter,
12
CardHeader,
13
CardTitle,
14
} from "~/components/ui/card";
15
import { Marker, MarkerContent } from "~/components/ui/marker";
16
import { Message, MessageContent } from "~/components/ui/message";
17
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
18
import { createScript, splitParagraphs } from "./message-scroller-utils";
19
20
const script = createScript("history", [
21
{
22
question: "Can you summarize the incident channel?",
23
answer:
24
"The first alert was a delayed export job. It started backing up around 09:42 UTC and triggered the warning once the retry queue crossed the threshold.\n\nNo customer-facing checkout paths were affected, but exports for larger workspaces were running about 12 minutes behind.",
25
},
26
{
27
question: "Was checkout affected?",
28
answer:
29
"No checkout errors were reported. Payment authorization, order creation, and confirmation emails stayed inside their normal latency bands.\n\nThe only elevated metric was export queue depth, which maps to analytics downloads instead of checkout.",
30
},
31
{
32
question: "What changed in the last deploy?",
33
answer:
34
"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.\n\nThe app deploy did not include checkout, pricing, or billing API changes.",
35
},
36
{
37
question: "Do we need to roll back?",
38
answer:
39
"Not yet. Queue depth is recovering after we reduced retry concurrency, and the oldest pending job is now under five minutes old.\n\nKeep rollback ready if the queue starts climbing again, but the current trend points toward recovery.",
40
},
41
{
42
question: "Keep watching for customer-visible issues.",
43
answer:
44
"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.\n\nIf those stay quiet through the next batch window, we can close this as an internal degradation.",
45
},
46
]);
47
48
const history = script.messages;
49
const INITIAL_VISIBLE_COUNT = 5;
50
51
export default function MessageScrollerLoadHistory() {
52
// Starts at 1 so the keyed Show below is always truthy and renders.
53
const [demoKey, setDemoKey] = createSignal(1);
54
const [visibleCount, setVisibleCount] = createSignal(INITIAL_VISIBLE_COUNT);
55
const visibleMessages = () => history.slice(-visibleCount());
56
const canLoadHistory = () => visibleCount() < history.length;
57
58
return (
59
<MessageScroller.Provider>
60
<div class="relative flex flex-col gap-4">
61
<Card class="mx-auto h-140 w-full max-w-sm gap-0">
62
<CardHeader class="gap-1 border-b">
63
<CardTitle>Load History</CardTitle>
64
<CardDescription>Prepended messages keep your place.</CardDescription>
65
<CardAction>
66
<Tooltip>
67
<TooltipTrigger as="span" class="inline-block w-fit">
68
<Button
69
type="button"
70
variant="outline"
71
size="icon"
72
aria-label="Reset loaded messages"
73
disabled={visibleCount() === INITIAL_VISIBLE_COUNT}
74
onClick={() => {
75
setVisibleCount(INITIAL_VISIBLE_COUNT);
76
setDemoKey((key) => key + 1);
77
}}
78
>
79
<RotateCwIcon />
80
</Button>
81
</TooltipTrigger>
82
<TooltipContent>
83
<p>Reset</p>
84
</TooltipContent>
85
</Tooltip>
86
</CardAction>
87
</CardHeader>
88
<CardContent class="min-h-0 flex-1 overflow-hidden p-0">
89
{/* Keyed so resetting recreates the scroller and re-seeds its
90
opening position, matching the upstream remount. */}
91
<Show when={demoKey()} keyed>
92
<MessageScroller.Root>
93
<MessageScroller.Viewport>
94
<MessageScroller.Content class="p-(--card-spacing)">
95
<For each={visibleMessages()}>
96
{(message) => {
97
const isUser = message.role === "user";
98
99
return (
100
<MessageScroller.Item messageId={message.id}>
101
<Message align={isUser ? "end" : "start"}>
102
<MessageContent>
103
<Bubble variant={isUser ? "muted" : "ghost"}>
104
<BubbleContent class="space-y-2">
105
<For each={splitParagraphs(message.text)}>
106
{(paragraph) => (
107
<p class="whitespace-pre-wrap">{paragraph}</p>
108
)}
109
</For>
110
</BubbleContent>
111
</Bubble>
112
</MessageContent>
113
</Message>
114
</MessageScroller.Item>
115
);
116
}}
117
</For>
118
<MessageScroller.Item scrollAnchor={false}>
119
<Marker variant="separator">
120
<MarkerContent>End of Conversation</MarkerContent>
121
</Marker>
122
</MessageScroller.Item>
123
</MessageScroller.Content>
124
</MessageScroller.Viewport>
125
<MessageScroller.Button />
126
</MessageScroller.Root>
127
</Show>
128
</CardContent>
129
<CardFooter class="flex flex-col items-center gap-2 border-t">
130
<Button
131
type="button"
132
class="w-full"
133
variant="secondary"
134
disabled={!canLoadHistory()}
135
onClick={() => setVisibleCount(history.length)}
136
>
137
{canLoadHistory() ? "Load History" : "History Loaded"}
138
</Button>
139
<p class="text-muted-foreground text-xs">
140
Restore earlier messages while keeping your place.
141
</p>
142
</CardFooter>
143
</Card>
144
<div class="mx-auto max-w-sm px-0.5 text-center text-muted-foreground text-xs text-balance">
145
Click Load History to load the entire conversation
146
</div>
147
</div>
148
</MessageScroller.Provider>
149
);
150
}

Animating New Messages

Animate a MessageScroller.Item with transform and opacity. Avoid height, margin, and padding animation because layout changes can fight the scroller's position work. Respect reduced-motion preferences while preserving the same scroll behavior.

Animation
Choose how user messages are animated when they are added to the conversation.
No Messages Yet
Click the button below to send the first message.
Select an animation then click send to see it in action.
1
import { ArrowUpIcon, MessageCircleDashedIcon, RotateCwIcon } from "lucide-solid";
2
import { createSignal, For, Show } from "solid-js";
3
import { MessageScroller } from "@/registry/kobalte/blocks/message-scroller";
4
import { Button } from "~/components/ui/button";
5
import {
6
Card,
7
CardAction,
8
CardContent,
9
CardDescription,
10
CardFooter,
11
CardHeader,
12
CardTitle,
13
} from "~/components/ui/card";
14
import {
15
Empty,
16
EmptyDescription,
17
EmptyHeader,
18
EmptyMedia,
19
EmptyTitle,
20
} from "~/components/ui/empty";
21
import {
22
Select,
23
SelectContent,
24
SelectItem,
25
SelectTrigger,
26
SelectValue,
27
} from "~/components/ui/select";
28
import {
29
createScript,
30
createScriptedChat,
31
MESSAGE_ANIMATIONS,
32
MessageAnimated,
33
type MessageAnimationId,
34
type MessageAnimationPreset,
35
} from "./message-scroller-utils";
36
37
const animationScript = createScript("animation", [
38
{
39
question: "Can user messages pop in like iMessage without breaking anchoring?",
40
answer:
41
"Yes. Animate the user row with transform and opacity, and let the assistant response stream normally below it.\n\nThat keeps the row measurement predictable while still giving the newly sent bubble a more tactile entrance.",
42
},
43
{
44
question: "What makes the animation feel more like iMessage?",
45
answer:
46
"Use a quick spring from the trailing edge: a little scale, a small upward move, and no layout animation.\n\nThe bubble feels tactile, but the measured row stays predictable, so anchoring and auto-scroll do not have to fight a changing layout.",
47
},
48
{
49
question: "Can I switch between presets while testing the same thread?",
50
answer:
51
"Yes. Keep the conversation in place while you change the preset, then send the next message to compare the new entrance against the same context.\n\nThat makes it easier to judge the difference between a subtle fade, a snappy pop, and a more dramatic 3D tilt without rebuilding the scenario each time.",
52
},
53
]);
54
55
const animationPresets = Object.values(MESSAGE_ANIMATIONS);
56
57
export default function MessageScrollerAnimation() {
58
const chat = createScriptedChat({ delayMs: 15, initialCount: 0, script: animationScript });
59
const [presetId, setPresetId] = createSignal<MessageAnimationId>("fade");
60
const preset = () => MESSAGE_ANIMATIONS[presetId()];
61
62
return (
63
<div class="relative flex flex-col gap-4">
64
<Card class="mx-auto h-140 w-full max-w-sm gap-0">
65
<CardHeader class="border-b">
66
<CardTitle>Animation</CardTitle>
67
<CardDescription>
68
Choose how user messages are animated when they are added to the conversation.
69
</CardDescription>
70
<CardAction class="flex items-center gap-2">
71
<Button
72
variant="outline"
73
size="icon"
74
aria-label="Reset animated messages"
75
disabled={chat.messages.length === 0 || chat.isBusy()}
76
onClick={chat.reset}
77
>
78
<RotateCwIcon />
79
</Button>
80
</CardAction>
81
</CardHeader>
82
<CardContent class="min-h-0 flex-1 overflow-hidden p-0">
83
<Show
84
when={chat.messages.length > 0}
85
fallback={
86
<Empty class="h-full">
87
<EmptyHeader>
88
<EmptyMedia variant="icon">
89
<MessageCircleDashedIcon />
90
</EmptyMedia>
91
<EmptyTitle>No Messages Yet</EmptyTitle>
92
<EmptyDescription>
93
Click the button below to send the first message.
94
</EmptyDescription>
95
</EmptyHeader>
96
</Empty>
97
}
98
>
99
<MessageScroller.Provider>
100
<MessageScroller.Root>
101
<MessageScroller.Viewport>
102
<MessageScroller.Content aria-busy={chat.isBusy()} class="p-(--card-spacing)">
103
<For each={chat.messages}>
104
{(message) => (
105
<MessageAnimated
106
message={message}
107
animationPreset={preset()}
108
userVariant="muted"
109
assistantVariant="ghost"
110
/>
111
)}
112
</For>
113
</MessageScroller.Content>
114
</MessageScroller.Viewport>
115
<MessageScroller.Button />
116
</MessageScroller.Root>
117
</MessageScroller.Provider>
118
</Show>
119
</CardContent>
120
<CardFooter class="border-t">
121
<Select<MessageAnimationPreset>
122
options={animationPresets}
123
optionValue="id"
124
optionTextValue="name"
125
placement="top-start"
126
value={preset()}
127
onChange={(value) => setPresetId(value?.id ?? "fade")}
128
itemComponent={(props) => (
129
<SelectItem item={props.item}>{props.item.rawValue.name}</SelectItem>
130
)}
131
>
132
<SelectTrigger aria-label="Animation preset">
133
<SelectValue<MessageAnimationPreset>>
134
{(state) => state.selectedOption().name}
135
</SelectValue>
136
</SelectTrigger>
137
<SelectContent />
138
</Select>
139
<Button
140
size="icon"
141
class="ml-auto"
142
disabled={!chat.nextMessage() || chat.isBusy()}
143
onClick={chat.send}
144
>
145
<ArrowUpIcon />
146
<span class="sr-only">Send Message</span>
147
</Button>
148
</CardFooter>
149
</Card>
150
<div class="mx-auto max-w-sm text-balance px-0.5 text-center text-muted-foreground text-xs">
151
Select an animation then click send to see it in action.
152
</div>
153
</div>
154
);
155
}

Jumping to Messages

useMessageScroller exposes commands from anywhere inside the provider, including controls outside the styled frame.

const { scrollToEnd, scrollToMessage, scrollToStart } = useMessageScroller();
scrollToMessage("message-id", { align: "start", behavior: "smooth" });

scrollToMessage uses the row messageId. It returns false when a target is not mounted and cannot be queued; a true result means the scroll ran or was queued.

Commands
Drive the transcript from outside.

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

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

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

What should I compare before we change the onboarding flow?

Compare three cohorts:

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

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

Can you turn that into an experiment?

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

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

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

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

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

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

Use the controls to jump to any message in the conversation.
1
import { For } from "solid-js";
2
import { MessageScroller, useMessageScroller } from "@/registry/kobalte/blocks/message-scroller";
3
import { Bubble, BubbleContent } from "~/components/ui/bubble";
4
import { Button } from "~/components/ui/button";
5
import {
6
Card,
7
CardAction,
8
CardContent,
9
CardDescription,
10
CardHeader,
11
CardTitle,
12
} from "~/components/ui/card";
13
import {
14
DropdownMenu,
15
DropdownMenuContent,
16
DropdownMenuGroup,
17
DropdownMenuItem,
18
DropdownMenuLabel,
19
DropdownMenuTrigger,
20
} from "~/components/ui/dropdown-menu";
21
import { Message, MessageContent } from "~/components/ui/message";
22
import { createScript, type DemoMessage, splitParagraphs } from "./message-scroller-utils";
23
24
const script = createScript("command", [
25
{
26
questionId: "command-activation",
27
question:
28
"We're seeing activation dip after workspace creation. Can you help me find the likely step?",
29
answer:
30
"The sharpest drop is between creating the workspace and inviting the first teammate.\n\nWorkspace 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.",
31
},
32
{
33
questionId: "command-compare",
34
question: "What should I compare before we change the onboarding flow?",
35
answer:
36
"Compare three cohorts:\n\n1. Users who choose a template before inviting teammates.\n2. Users who start from a blank workspace.\n3. Users who skip invites and return within 24 hours.\n\nIf template users invite faster, the fix is probably better first-run guidance rather than a louder invite prompt.",
37
},
38
{
39
questionId: "command-experiment",
40
question: "Can you turn that into an experiment?",
41
answer:
42
"Yes. Create a variant that shows a short checklist after workspace creation:\n\n- Pick a template.\n- Add one project detail.\n- Invite a teammate when the workspace has context.\n\nMeasure first invite completion, 24-hour return rate, and whether teams create a second project.",
43
},
44
{
45
questionId: "command-risk",
46
question: "What's the risk if we delay the invite prompt?",
47
answer:
48
"The main risk is reducing team creation for accounts that already know who they want to invite.\n\nTo 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.",
49
},
50
]);
51
52
export default function MessageScrollerCommands() {
53
return (
54
<MessageScroller.Provider defaultScrollPosition="end">
55
<div class="relative flex flex-col gap-4">
56
<Card class="mx-auto h-140 w-full max-w-sm gap-0">
57
<CardHeader class="gap-1 border-b">
58
<CardTitle>Commands</CardTitle>
59
<CardDescription>Drive the transcript from outside.</CardDescription>
60
<CardAction>
61
<CommandMenu />
62
</CardAction>
63
</CardHeader>
64
<CardContent class="min-h-0 flex-1 overflow-hidden p-0">
65
<MessageScroller.Root>
66
<MessageScroller.Viewport>
67
<MessageScroller.Content class="p-(--card-spacing)">
68
<For each={script.messages}>
69
{(message) => {
70
const isUser = message.role === "user";
71
72
return (
73
<MessageScroller.Item messageId={message.id} scrollAnchor={isUser}>
74
<Message align={isUser ? "end" : "start"}>
75
<MessageContent>
76
<Bubble variant={isUser ? "muted" : "ghost"}>
77
<BubbleContent class="space-y-2">
78
<For each={splitParagraphs(message.text)}>
79
{(paragraph) => <p class="whitespace-pre-wrap">{paragraph}</p>}
80
</For>
81
</BubbleContent>
82
</Bubble>
83
</MessageContent>
84
</Message>
85
</MessageScroller.Item>
86
);
87
}}
88
</For>
89
</MessageScroller.Content>
90
</MessageScroller.Viewport>
91
<MessageScroller.Button />
92
</MessageScroller.Root>
93
</CardContent>
94
</Card>
95
<div class="mx-auto max-w-sm px-0.5 text-center text-muted-foreground text-xs text-balance">
96
Use the controls to jump to any message in the conversation.
97
</div>
98
</div>
99
</MessageScroller.Provider>
100
);
101
}
102
103
function CommandMenu() {
104
const { scrollToMessage } = useMessageScroller();
105
106
return (
107
<DropdownMenu placement="bottom-end">
108
<DropdownMenuTrigger as={Button} type="button" variant="secondary">
109
Jump to...
110
</DropdownMenuTrigger>
111
<DropdownMenuContent class="w-64">
112
<DropdownMenuGroup>
113
<DropdownMenuLabel>Conversations</DropdownMenuLabel>
114
<For each={script.userMessages}>
115
{(message) => (
116
<DropdownMenuItem
117
onSelect={() => scrollToMessage(message.id, { align: "start", behavior: "smooth" })}
118
>
119
<span class="line-clamp-1 min-w-0">{getTrimmedMessageText(message)}</span>
120
</DropdownMenuItem>
121
)}
122
</For>
123
</DropdownMenuGroup>
124
</DropdownMenuContent>
125
</DropdownMenu>
126
);
127
}
128
129
function getTrimmedMessageText(message: DemoMessage) {
130
return message.text.length > 42 ? `${message.text.slice(0, 39)}...` : message.text;
131
}

Tracking the Reader's Position

useMessageScrollerVisibility reports the current anchored turn and the message ids currently visible in document order. Tracking is pay-for-what-you-use: it only runs while a consumer subscribes, and rows need messageId values to participate.

Transcript Outline
Track the current anchored turn.

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

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

What was the customer impact?

Impact was limited to delayed processing.

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

What actions are open?

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

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

Give me the follow-up checklist.

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

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

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

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

Open the outline to jump between anchored turns as you read.
1
import { For } from "solid-js";
2
import {
3
MessageScroller,
4
useMessageScroller,
5
useMessageScrollerVisibility,
6
} from "@/registry/kobalte/blocks/message-scroller";
7
import { Bubble, BubbleContent } from "~/components/ui/bubble";
8
import {
9
Card,
10
CardContent,
11
CardDescription,
12
CardHeader,
13
CardTitle,
14
} from "~/components/ui/card";
15
import { HoverCard, HoverCardContent, HoverCardTrigger } from "~/components/ui/hover-card";
16
import { Message, MessageContent } from "~/components/ui/message";
17
import { createScript, type DemoMessage, splitParagraphs } from "./message-scroller-utils";
18
19
const script = createScript("vis", [
20
{
21
questionId: "vis-brief",
22
question: "Review the incident handoff and tell me what to read first.",
23
answer:
24
"Start with the summary and the impact section. The regression affected the upload queue, but the recovery path completed for every queued job.",
25
},
26
{
27
questionId: "vis-impact",
28
question: "What was the customer impact?",
29
answer:
30
"Impact was limited to delayed processing.\n\nNo 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.",
31
},
32
{
33
questionId: "vis-actions",
34
question: "What actions are open?",
35
answer:
36
"Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.\n\nThe alert should fire on sustained queue growth, not a single short spike.",
37
},
38
{
39
questionId: "vis-checklist",
40
question: "Give me the follow-up checklist.",
41
answer:
42
"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.\n\nI 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.\n\nKeep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.\n\nThe alert should fire on sustained queue growth, not a single short spike.",
43
},
44
]);
45
46
export default function MessageScrollerVisibility() {
47
return (
48
<MessageScroller.Provider scrollMargin={12}>
49
<div class="relative flex flex-col gap-4">
50
<div class="relative mx-auto w-full max-w-sm">
51
<Card class="h-140 w-full gap-0">
52
<CardHeader class="gap-1 border-b">
53
<CardTitle>Transcript Outline</CardTitle>
54
<CardDescription>Track the current anchored turn.</CardDescription>
55
</CardHeader>
56
<CardContent class="min-h-0 flex-1 overflow-hidden p-0">
57
<MessageScroller.Root>
58
<MessageScroller.Viewport>
59
<MessageScroller.Content class="p-(--card-spacing)">
60
<For each={script.messages}>
61
{(message) => {
62
const isUser = message.role === "user";
63
64
return (
65
<MessageScroller.Item messageId={message.id} scrollAnchor={isUser}>
66
<Message align={isUser ? "end" : "start"}>
67
<MessageContent>
68
<Bubble variant={isUser ? "muted" : "ghost"}>
69
<BubbleContent class="space-y-2">
70
<For each={splitParagraphs(message.text)}>
71
{(paragraph) => (
72
<p class="whitespace-pre-wrap">{paragraph}</p>
73
)}
74
</For>
75
</BubbleContent>
76
</Bubble>
77
</MessageContent>
78
</Message>
79
</MessageScroller.Item>
80
);
81
}}
82
</For>
83
</MessageScroller.Content>
84
</MessageScroller.Viewport>
85
<MessageScroller.Button />
86
</MessageScroller.Root>
87
</CardContent>
88
</Card>
89
<div class="-right-12 -translate-y-1/2 absolute top-1/2">
90
<TranscriptOutline />
91
</div>
92
</div>
93
<div class="mx-auto max-w-sm px-0.5 text-center text-muted-foreground text-xs">
94
Open the outline to jump between anchored turns as you read.
95
</div>
96
</div>
97
</MessageScroller.Provider>
98
);
99
}
100
101
function TranscriptOutline() {
102
const { scrollToMessage } = useMessageScroller();
103
// `currentAnchorId` is a getter; read it through the object at each use site
104
// so the outline stays reactive. Destructuring would freeze it.
105
const visibility = useMessageScrollerVisibility();
106
107
return (
108
<HoverCard placement="left">
109
<HoverCardTrigger
110
as="button"
111
type="button"
112
aria-label="Open transcript outline"
113
class="flex h-9 w-9 flex-col items-center justify-center gap-1 rounded-md outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50"
114
>
115
<For each={script.userMessages}>
116
{(message) => (
117
<span
118
data-current={message.id === visibility.currentAnchorId}
119
class="h-0.5 w-4 rounded-full bg-muted-foreground/40 data-[current=true]:bg-foreground"
120
/>
121
)}
122
</For>
123
</HoverCardTrigger>
124
<HoverCardContent class="flex w-64 flex-col gap-1 rounded-2xl p-1">
125
<For each={script.userMessages}>
126
{(message) => (
127
<button
128
type="button"
129
aria-current={visibility.currentAnchorId === message.id ? "location" : undefined}
130
class="flex min-h-7 items-center rounded-xl px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-current:bg-accent aria-current:text-accent-foreground"
131
onClick={() => scrollToMessage(message.id, { align: "start", behavior: "smooth" })}
132
>
133
<span class="line-clamp-1 min-w-0">{getTrimmedMessageText(message)}</span>
134
</button>
135
)}
136
</For>
137
</HoverCardContent>
138
</HoverCard>
139
);
140
}
141
142
function getTrimmedMessageText(message: DemoMessage) {
143
return message.text.length > 42 ? `${message.text.slice(0, 39)}...` : message.text;
144
}

Reading Scroll State

useMessageScrollerScrollable reports whether the viewport can still scroll toward the start or end. The opposite tells you that the corresponding edge has been reached. For styles, prefer the root data-scrollable state.

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

Review scroll checkpoint 1.

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

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

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

Review scroll checkpoint 3.

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

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

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

Review scroll checkpoint 5.

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

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

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

Review scroll checkpoint 7.

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

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

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

Review scroll checkpoint 9.

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

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

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

Review scroll checkpoint 11.

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

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

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

All messages fit in the viewport.
Scroll the transcript to see the footer update.
1
import { For } from "solid-js";
2
import {
3
MessageScroller,
4
useMessageScrollerScrollable,
5
} from "@/registry/kobalte/blocks/message-scroller";
6
import {
7
Card,
8
CardContent,
9
CardDescription,
10
CardFooter,
11
CardHeader,
12
CardTitle,
13
} from "~/components/ui/card";
14
import { type DemoMessage, MessageAnimated } from "./message-scroller-utils";
15
16
const messages: DemoMessage[] = Array.from({ length: 12 }, (_, index) => ({
17
id: `scrollable-${index + 1}`,
18
role: index % 2 === 0 ? "user" : "assistant",
19
text:
20
index % 2 === 0
21
? `Review scroll checkpoint ${index + 1}.`
22
: `Checkpoint ${index + 1} is synced. The scrollable hook updates as the viewport moves.\n\nWhen 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.\n\nAt the latest message, the footer should switch again and only point them back up.`,
23
}));
24
25
export default function MessageScrollerScrollable() {
26
return (
27
<div class="mx-auto flex w-full max-w-sm flex-col gap-4">
28
<Card class="h-140 w-full gap-0 overflow-hidden">
29
<CardHeader class="gap-1 border-b">
30
<CardTitle>Scroll Status</CardTitle>
31
<CardDescription>
32
Where the reader can go scroll to based on current scroll position.
33
</CardDescription>
34
</CardHeader>
35
<MessageScroller.Provider defaultScrollPosition="start">
36
<CardContent class="min-h-0 flex-1 overflow-hidden p-0">
37
<MessageScroller.Root>
38
<MessageScroller.Viewport>
39
<MessageScroller.Content class="gap-4 p-(--card-spacing)">
40
<For each={messages}>
41
{(message) => (
42
<MessageAnimated
43
message={message}
44
scrollAnchor={message.role === "user"}
45
userVariant="muted"
46
assistantVariant="ghost"
47
/>
48
)}
49
</For>
50
</MessageScroller.Content>
51
</MessageScroller.Viewport>
52
<MessageScroller.Button />
53
</MessageScroller.Root>
54
</CardContent>
55
<ScrollStateFooter />
56
</MessageScroller.Provider>
57
</Card>
58
<div class="px-0.5 text-center text-muted-foreground text-xs">
59
Scroll the transcript to see the footer update.
60
</div>
61
</div>
62
);
63
}
64
65
function ScrollStateFooter() {
66
// The hook returns getters; reading them inside the thunk keeps the footer
67
// reactive. Destructuring here would freeze the status at its first value.
68
const scrollable = useMessageScrollerScrollable();
69
const status = () => getScrollStatus(scrollable.start, scrollable.end);
70
71
return (
72
<CardFooter class="justify-center border-t text-center text-muted-foreground text-sm">
73
{status()}
74
</CardFooter>
75
);
76
}
77
78
function getScrollStatus(start: boolean, end: boolean) {
79
if (start && end) {
80
return "You can scroll both ways.";
81
}
82
83
if (end) {
84
return "You are at the top. You can only scroll down.";
85
}
86
87
if (start) {
88
return "You are at the bottom. You can only scroll up.";
89
}
90
91
return "All messages fit in the viewport.";
92
}

Performance

The scroll hot path is imperative: transcript rows do not rerender for each scroll event. The root and viewport mirror relevant state through data attributes, and each item uses content-visibility: auto with an intrinsic size so far-off rows can avoid paint work while remaining present for selection, find-in-page, server rendering, and assistive technology.

For the expected range of hundreds to low thousands of rich transcript rows, real DOM items preserve the best selection and accessibility behavior. When a much larger transcript needs virtualization, use MessageScroller.Viewport as the virtualizer's scroll element and let the virtualizer own the rows.

Virtualization

Virtualization is intentionally left outside the primitive. MessageScroller renders real DOM rows and stays fast well into the thousands of turns (see Performance), so most transcripts never need it.

When a transcript is large enough to need virtualization, use MessageScroller.Viewport as the scroll element and let the virtualizer own the rows.

1
import { createVirtualizer } from "@tanstack/solid-virtual";
2
import type { JSX } from "solid-js";
3
import { For, Show } from "solid-js";
4
import { MessageScroller } from "~/components/blocks/message-scroller";
5
6
function VirtualizedTranscript(props: { messages: { id: string; content: JSX.Element }[] }) {
7
let viewportRef: HTMLDivElement | undefined;
8
9
const virtualizer = createVirtualizer({
10
get count() {
11
return props.messages.length;
12
},
13
getScrollElement: () => viewportRef ?? null,
14
estimateSize: () => 86,
15
getItemKey: (index) => props.messages[index]?.id ?? index,
16
overscan: 8,
17
});
18
19
return (
20
<MessageScroller.Provider>
21
<MessageScroller.Root>
22
<MessageScroller.Viewport ref={(element) => (viewportRef = element)}>
23
<MessageScroller.Content class="block min-h-full">
24
<div class="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
25
<For each={virtualizer.getVirtualItems()}>
26
{(virtualItem) => {
27
const message = () => props.messages[virtualItem.index];
28
29
return (
30
<Show when={message()}>
31
{(item) => (
32
<div
33
data-index={virtualItem.index}
34
ref={virtualizer.measureElement}
35
class="absolute start-0 top-0 w-full"
36
style={{ transform: `translateY(${virtualItem.start}px)` }}
37
>
38
<Bubble>{item().content}</Bubble>
39
</div>
40
)}
41
</Show>
42
);
43
}}
44
</For>
45
</div>
46
</MessageScroller.Content>
47
</MessageScroller.Viewport>
48
<MessageScroller.Button />
49
</MessageScroller.Root>
50
</MessageScroller.Provider>
51
);
52
}

Accessibility

MessageScroller.Content defaults to role="log" and aria-relevant="additions". Set aria-busy while a response is streaming so assistive technology receives a coherent update. The viewport is a labelled, keyboard-focusable region; scroll keys signal reader intent, and the inactive scroll button is removed from tab order.

<MessageScroller.Content aria-busy={status() === "streaming"}>
{/* messages */}
</MessageScroller.Content>

API Reference

This is native Solid composition rather than a wrapped primitive. The full implementation lives in the Message Scroller source.

MessageScroller.Provider

The headless root. It owns scroll state and the behavior props, provides them to the parts and hooks, and renders no DOM of its own.

PropTypeDefaultDescription
autoScrollbooleanfalseFollow new content only while the reader is already at the live edge. Wheel, touch, keyboard scroll, and explicit jumps release it.
defaultScrollPosition"start" | "end" | "last-anchor""end"Opening position on the first non-empty render, applied once. "last-anchor" opens at the last scrollAnchor row and falls back to "end" when the turn fits or no anchor exists.
scrollEdgeThresholdnumber8Distance from either edge that still counts as being at the start or end. Drives state attributes and scroll-button visibility.
scrollMarginnumber0Margin applied to the aligned edge for scrollToMessage, visibility, and programmatic targets.
scrollPreviousItemPeeknumber64Extra margin added to scrollMargin when a newly appended scrollAnchor item is positioned, so part of the previous item stays visible.

MessageScroller.Root

The styled frame and layout container. It fills its parent, so use it inside a height-constrained layout, within a MessageScroller.Provider. Remaining props spread onto the frame div.

The root mirrors the scroll-state attributes below (the viewport carries them too), so you can style the container by scroll state, such as edge fades on the frame.

Data attributeValueDescription
data-scrollable"start" | "end" | "start end" | absentEdges the viewport can scroll toward. Query one with [data-scrollable~="end"]; absent means the content fits.
data-autoscrollingpresentPresent while the viewport is programmatically scrolling to the latest message.

MessageScroller.Viewport

The scrollable viewport. Remaining props spread onto the viewport div, and it carries the same data attributes as the root.

PropTypeDefaultDescription
preserveScrollOnPrependbooleantrueKeep the first visible message row stable when older rows are prepended.
rolestring"region"Landmark role for the labelled scrollable transcript viewport.
aria-labelstring"Messages"Accessible name for the scrollable chat transcript.
tabIndexnumber0Makes the transcript viewport keyboard-scrollable.

MessageScroller.Content

The transcript content element. Every direct child should be a MessageScroller.Item. Remaining props spread onto the content div.

PropTypeDefaultDescription
rolestring"log"ARIA role applied to the message list for live announcements.
aria-relevantstring"additions"Live-region updates to announce. Defaults to new transcript rows only.
aria-busyboolean-Marks the live region busy while a turn streams, if needed.
spacerClassNamestring-Class for the internal spacer used to make room for anchored rows.

MessageScroller.Item

One transcript row: a message, marker, typing row, separator, or load-more row. Remaining props spread onto the item div.

PropTypeDefaultDescription
messageIdstring-Stable row id used by scrollToMessage, visibility, and prepend preservation.
scrollAnchorbooleanfalseMarks this row as a turn boundary that can anchor newly appended turns.
Data attributeValueDescription
data-message-idstringMirrors messageId when provided.
data-scroll-anchor"true" | "false"Mirrors scrollAnchor.

MessageScroller.Button

A button that scrolls to the start or end of the transcript. It is inert and removed from the tab order when there is nothing to scroll toward. Remaining props are forwarded to the underlying Button, including variant (default "secondary") and size (default "icon-sm").

PropTypeDefaultDescription
behaviorScrollBehavior"smooth"Native scroll behavior used when the button scrolls to its target edge.
direction"start" | "end""end"Transcript edge the button scrolls toward.
childrenJSX.Element-Custom button content. Defaults to the scroll icon and accessible label.
render(props, state) => JSX.Element-Custom render target. Solid JSX elements are real DOM nodes, so pass a render function instead of an element.
Data attributeValueDescription
data-direction"start" | "end"Mirrors direction.
data-active"true" | "false"Whether this button can currently scroll.

useMessageScroller

Imperative transcript commands, available anywhere inside the provider.

MethodTypeDescription
scrollToMessage(messageId: string, options?) => booleanScroll to a message row by id.
scrollToEnd(options?) => booleanScroll to the latest message.
scrollToStart(options?) => booleanScroll to the top.

All commands return false when the command could not be applied. scrollToStart and scrollToEnd return false only when the viewport is not mounted yet. scrollToMessage returns false when the target is not mounted and cannot be queued.

Command options:

OptionTypeDefaultDescription
align"start" | "center" | "end" | "nearest""start"How a message target aligns in the viewport.
behaviorScrollBehavior"auto"Native scroll behavior for the command.
scrollMarginnumberprovider scrollMarginMargin applied to the aligned edge for this command.

useMessageScrollerScrollable

Which edges the viewport can scroll toward, for sibling UI that needs the values in JavaScript. Prefer the data-scrollable attribute for styling the scroller itself.

ValueTypeDescription
startbooleanWhether the viewport can scroll toward the start. Content is hidden above (!start means at the top).
endbooleanWhether the viewport can scroll toward the end. Content is hidden below (!end means at the bottom). Stays false while follow-output keeps the reader at the live edge.

useMessageScrollerVisibility

Visibility state for outline, search, and active-turn UI. It subscribes separately from useMessageScrollerScrollable, so visibility work is only paid for when a consumer needs it.

ValueTypeDescription
currentAnchorIdstring | nullThe current anchored turn, based on the last scrollAnchor item at or above the reading line.
visibleMessageIdsstring[]Message ids intersecting the viewport, in document order.

Filter visibleMessageIds in your app when you need a narrower outline, such as user messages, anchored turns, or search hits.

On This Page

  • What Makes a Great Streaming Chat Experience
  • MessageScroller
  • Installation
  • Usage
  • Composition
  • Core Concepts
    • Anchoring Turns
    • Group Chat
    • Keeping Context Visible
    • Following the Live Edge
    • Opening Saved Threads
    • Loading Earlier Messages
    • Animating New Messages
    • Jumping to Messages
    • Tracking the Reader's Position
    • Reading Scroll State
  • Performance
  • Virtualization
  • Accessibility
  • API Reference
    • MessageScroller.Provider
    • MessageScroller.Root
    • MessageScroller.Viewport
    • MessageScroller.Content
    • MessageScroller.Item
    • MessageScroller.Button
    • useMessageScroller
    • useMessageScrollerScrollable
    • useMessageScrollerVisibility
Built by Kevin Abatan. The source code is available on GitHub.