Message Scroller
Stick-to-bottom transcript scroller with Jump to latest, prepend helpers, and Loading / Empty / Error chrome.
Plus Registry
This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus on the Installation page, then install @f-ui-plus/message-scroller.
Engine Stays f-ui
Row layout (Message, Bubble, Marker, Attachment) tracks shadcn. Message Scroller and Prompt Composer remain f-ui engines — stick-to-bottom, Jump, and composer chrome are not shadcn re-exports.
Full-Bleed Scrollport
On product pages, keep MessageScrollerViewport full width of the main
column so the scrollbar sits at the pane edge. Put the reading measure
inside Content with Chat Measure Band
(mode="content", default), and wrap the composer with
ChatMeasureBand mode="chrome". Viewport’s scrollbar-gutter: stable
narrows the scroll content box — if the composer only uses a bare
max-w-3xl (no mirrored gutter), the transcript shifts left and assistant
avatars look cut past the input edge. Do not wrap Viewport in max-w-*,
and do not use mode="chrome" inside Content (double gutter). Docs card
demos may still use a local scrollport inside a bordered frame.
Message Scroller is the chat transcript shell: stick-to-bottom while streaming, Jump to latest, optional prepend scroll preservation, and presentational Loading / Empty / Error chrome. The host owns fetch, transport, and which branch to mount; the scroller ships the parts. Compose Message, Bubble, Marker, and Prompt Composer as siblings under one MessageScrollerProvider. Assistant markdown uses Markdown Renderer with isStreaming — see the recipe demo below.
When To Use
- You need a presentational chat list that sticks to the bottom during token streams.
- Initial hydrate should show a skeleton (or labelled spinner) instead of a blank pane.
- A successful empty thread needs a welcome empty with a next action or suggestion pills.
- Thread load failures and turn generation failures need recoverable Error / Turn Error chrome.
- Users scroll up to read history and need a Jump to latest control.
- Prefer a plain scroll container when you do not need stick-follow, Jump, or async chrome.
Features
| Area | Behavior |
|---|---|
| Stick-to-bottom | Engine is use-stick-to-bottom; streaming default resize="instant"; Viewport uses overflow-anchor: none and re-anchors when the scrollport shrinks while stuck (composer grow); pauses follow while the user interacts with nested overflow ports (markdown tables, code fences) so growth does not cancel those gestures |
| Auto-scroll gate | autoScroll={false} disconnects resize follow; manual scroll helpers still work |
| Jump | MessageScrollerButton — circular ArrowDown when not at bottom (aria-label i18n; override with children) |
| Prepend | Mount heuristic + compensateScrollTopAfterPrepend for host-controlled loads |
| Items | Every row under Content must use MessageScrollerItem with messageId |
| Content mode | Default mode="log" is a live region; mode="state" for exclusive Loading / Empty / fill Error takeovers |
| Loading | MessageScrollerLoading — default skeleton / spinner, or renderRow / children customization inside state-mode Content |
| Empty | MessageScrollerEmpty — welcome copy, optional action / suggestions / children takeover |
| Error | MessageScrollerError — fill (initial) or inline (background refresh); MessageTurnError for per-turn recovery |
| Composer | Sibling of the scroller under the same Provider; call scrollToEnd() on send |
Streaming stick
Pinned-to-bottom (not “scroll on every token”):
- While the user is at / near the bottom, content growth and scrollport shrink keep the latest tokens in view with
resize="instant". - If the user scrolls up, follow stops;
MessageScrollerButtonjumps back and re-pins. - Nested overflow (wide GFM tables, code fences): stick pauses for the gesture, then re-anchors if still near bottom — it does not unlock Jump.
- Do not set Provider
resize="smooth"for token streams — spring/smooth follow lags and can drop the stick lock mid-stream. - Host tips: batch high-frequency stream updates; do not trigger scroll only when
messages.lengthchanges (in-place streaming won’t fire). Late layout (syntax highlight, images) still re-pins while near bottom.
Streaming Anti-Patterns
Do not ship these common “fixes” that break stick-to-bottom:
- Scroll on every token/chunk with no near-bottom gate (user cannot read history).
resize="smooth"orscrollTo({ behavior: "smooth" })during token streams (lags behind variable-size growth).- Unlock stick from bare
scrollevents without distinguishing content growth from user scroll-up. - Timeout-based “user is scrolling” flags or polling
scrollTopon an interval. - Scroll triggers keyed only on
messages.length(in-place streaming updates will not fire).
Installing
Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.
FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/message-scrollerFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/message-scrollerFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/message-scrollerFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/message-scrollerregistryDependencies: button, fui-i18n, skeleton. Runtime: use-stick-to-bottom.
Usage
Host the decision order yourself — the scroller does not infer state from child count. Use mode="state" for exclusive takeovers; switch to default mode="log" when messages are present.
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerEmpty,
MessageScrollerError,
MessageScrollerItem,
MessageScrollerLoading,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/f-ui/message-scroller/message-scroller";
const hasUsableMessages = messages.length > 0;
<MessageScrollerProvider>
<MessageScroller className="h-96">
<MessageScrollerViewport>
{!hasUsableMessages && isPending ? (
<MessageScrollerContent mode="state">
<MessageScrollerLoading />
</MessageScrollerContent>
) : !hasUsableMessages && threadError ? (
<MessageScrollerContent mode="state">
<MessageScrollerError onRetry={refetch} />
</MessageScrollerContent>
) : !hasUsableMessages && isSuccess ? (
<MessageScrollerContent mode="state">
<MessageScrollerEmpty />
</MessageScrollerContent>
) : (
<>
{threadError ? (
<MessageScrollerError variant="inline" onRetry={refetch} />
) : null}
<MessageScrollerContent>
{messages.map((m) => (
<MessageScrollerItem key={m.id} messageId={m.id}>
{/* Message / Marker / MessageTurnError / host chrome */}
</MessageScrollerItem>
))}
</MessageScrollerContent>
</>
)}
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
{/* PromptComposer sibling — call useMessageScroller().scrollToEnd on send */}
</MessageScrollerProvider>Examples
Streaming Transcript + Composer Pin
Fake token stream with Jump. The sibling composer uses the same Actions + icon Send / Stop recipe as Prompt Composer (optional pending Attachment chip via Attach files) and calls scrollToEnd() on send so new turns pin to the bottom.
"use client";
import { useEffect, useState } from "react";
import { ArrowUpIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
type AttachmentState,
} from "@/components/f-ui/attachment/attachment";
import { Bubble, BubbleContent } from "@/components/f-ui/bubble/bubble";
import { FileTypeIcon } from "@/components/f-ui/file-type-icon/file-type-icon";
import {
Message,
MessageContent,
} from "@/components/f-ui/message/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
} from "@/components/f-ui/message-scroller/message-scroller";
import {
PromptComposer,
PromptComposerActions,
PromptComposerSubmit,
PromptComposerTextarea,
} from "@/components/f-ui/prompt-composer/prompt-composer";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type DemoMsg = { id: string; role: "user" | "assistant"; text: string };
type DemoAttachment = {
id: string;
fileName: string;
description: string;
state: AttachmentState;
};
const STREAM_TEXT =
"Stick-to-bottom keeps the viewport pinned while tokens arrive. Scroll up to reveal Jump to latest — then click it to return.";
const HISTORY: DemoMsg[] = [
{
id: "h1",
role: "user",
text: "Can you summarize how stick-to-bottom should feel during a long reply?",
},
{
id: "h2",
role: "assistant",
text: "While tokens stream, the transcript should stay pinned to the newest line so you do not chase the caret.",
},
{
id: "h3",
role: "user",
text: "What if I scroll up to read an earlier turn?",
},
{
id: "h4",
role: "assistant",
text: "Auto-follow pauses. A quiet Jump to latest control appears so you can rejoin the live edge when ready.",
},
{
id: "m1",
role: "user",
text: "Show me stick-to-bottom while streaming.",
},
];
function ComposerWithPin({
onSend,
isLoading,
onStop,
}: {
onSend: (text: string) => void;
isLoading: boolean;
onStop: () => void;
}) {
const { scrollToEnd } = useMessageScroller();
const [attachments, setAttachments] = useState<DemoAttachment[]>([]);
function addFakeAttachment() {
const id = `att-${Date.now()}`;
const fileName = "brief.pdf";
setAttachments((prev) => [
...prev,
{
id,
fileName,
description: "PDF · uploading…",
state: "uploading",
},
]);
window.setTimeout(() => {
setAttachments((prev) =>
prev.map((item) =>
item.id === id
? { ...item, state: "done", description: "PDF · 96 KB" }
: item,
),
);
}, 1200);
}
return (
<PromptComposer
isLoading={isLoading}
onSubmit={(text) => {
onSend(text);
void scrollToEnd();
}}
onStop={onStop}
>
{attachments.length > 0 ? (
<AttachmentGroup aria-label="Pending attachments" className="pb-1">
{attachments.map((item) => (
<Attachment
key={item.id}
state={item.state}
size="sm"
className="w-56"
>
<AttachmentMedia variant="icon">
<FileTypeIcon fileName={item.fileName} />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{item.fileName}</AttachmentTitle>
<AttachmentDescription>
{item.description}
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<Tooltip>
<TooltipTrigger asChild>
<AttachmentAction
type="button"
aria-label={`Remove ${item.fileName}`}
onClick={() =>
setAttachments((prev) =>
prev.filter((a) => a.id !== item.id),
)
}
>
<XIcon />
</AttachmentAction>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
</AttachmentActions>
</Attachment>
))}
</AttachmentGroup>
) : null}
<PromptComposerTextarea />
<PromptComposerActions className="w-full justify-between">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon-sm"
variant="outline"
aria-label="Add"
>
<PlusIcon />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Add</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" side="top">
<DropdownMenuItem onSelect={addFakeAttachment}>
Attach files
</DropdownMenuItem>
<DropdownMenuItem disabled>Create image</DropdownMenuItem>
<DropdownMenuItem disabled>Web search</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<PromptComposerSubmit>
{isLoading ? <SquareIcon /> : <ArrowUpIcon />}
</PromptComposerSubmit>
</TooltipTrigger>
<TooltipContent>{isLoading ? "Stop" : "Send"}</TooltipContent>
</Tooltip>
</PromptComposerActions>
</PromptComposer>
);
}
export function MessageScrollerDemo() {
const [messages, setMessages] = useState<DemoMsg[]>(HISTORY);
const [streamId, setStreamId] = useState<string | null>("m2");
const [streamText, setStreamText] = useState("");
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!streamId) return;
let i = 0;
const id = window.setInterval(() => {
i += 2;
if (i >= STREAM_TEXT.length) {
setStreamText(STREAM_TEXT);
setMessages((prev) => [
...prev.filter((m) => m.id !== streamId),
{ id: streamId, role: "assistant", text: STREAM_TEXT },
]);
setStreamId(null);
setIsLoading(false);
window.clearInterval(id);
return;
}
setStreamText(STREAM_TEXT.slice(0, i));
}, 40);
return () => window.clearInterval(id);
}, [streamId]);
const rows: DemoMsg[] = streamId
? [
...messages.filter((m) => m.id !== streamId),
{ id: streamId, role: "assistant", text: streamText },
]
: messages;
return (
<TooltipProvider>
<MessageScrollerProvider className="flex h-[360px] flex-col gap-3">
<MessageScroller className="min-h-0 flex-1 rounded-xl border border-border">
<MessageScrollerViewport>
<MessageScrollerContent className="p-4">
{rows.map((msg) => (
<MessageScrollerItem key={msg.id} messageId={msg.id}>
<Message align={msg.role === "user" ? "end" : "start"}>
<MessageContent>
{msg.role === "user" ? (
<Bubble>
<BubbleContent>
<p className="whitespace-pre-wrap">{msg.text}</p>
</BubbleContent>
</Bubble>
) : (
<p className="text-sm whitespace-pre-wrap">{msg.text}</p>
)}
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
<ComposerWithPin
isLoading={isLoading}
onStop={() => {
setStreamId(null);
setIsLoading(false);
}}
onSend={(text) => {
const userId = `u-${Date.now()}`;
const assistantId = `a-${Date.now()}`;
setMessages((prev) => [
...prev,
{ id: userId, role: "user", text },
]);
setStreamText("");
setStreamId(assistantId);
setIsLoading(true);
}}
/>
</MessageScrollerProvider>
</TooltipProvider>
);
}Assistant-First Markdown Recipe
User turns use a solid Bubble. Assistant turns render Markdown Renderer without a filled bubble, with isStreaming while the source grows. Toggle loading / empty / error / populated to see the host decision order — pending with messages=[] shows Loading, not Empty.
"use client";
import { useEffect, useState } from "react";
import { Bubble, BubbleContent } from "@/components/f-ui/bubble/bubble";
import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer";
import {
Message,
MessageContent,
} from "@/components/f-ui/message/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerEmpty,
MessageScrollerError,
MessageScrollerItem,
MessageScrollerLoading,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/f-ui/message-scroller/message-scroller";
import { Button } from "@/components/ui/button";
type DemoMsg = {
id: string;
role: "user" | "assistant";
text: string;
};
type RecipeState = "loading" | "empty" | "error" | "populated";
const STATES: RecipeState[] = ["loading", "empty", "error", "populated"];
// Avoid ATX headings in docs demos — they pollute the page outline / TOC.
const ASSISTANT_MD = [
"**Assistant-first recipe**",
"",
"User turns use a **Bubble**. Assistant replies render **Markdown Renderer**",
"without a solid bubble so code fences keep full measure.",
"",
"```ts",
'console.log("streaming markdown");',
"```",
"",
"Pass `isStreaming` while tokens arrive.",
].join("\n");
const POPULATED_USER: DemoMsg = {
id: "u1",
role: "user",
text: "Show the assistant-first markdown layout.",
};
export function MessageScrollerAssistantRecipeDemo() {
const [recipeState, setRecipeState] = useState<RecipeState>("populated");
const [visibleLength, setVisibleLength] = useState(0);
const [runId, setRunId] = useState(0);
const [isRetrying, setIsRetrying] = useState(false);
useEffect(() => {
if (recipeState !== "populated") return;
setVisibleLength(0);
const id = window.setInterval(() => {
setVisibleLength((prev) => {
if (prev >= ASSISTANT_MD.length) {
window.clearInterval(id);
return prev;
}
return prev + 3;
});
}, 28);
return () => window.clearInterval(id);
}, [runId, recipeState]);
const messages: DemoMsg[] =
recipeState === "populated" ? [POPULATED_USER] : [];
const hasUsableMessages = messages.length > 0;
const isPending = recipeState === "loading";
const isSuccess = recipeState === "empty";
const threadError = recipeState === "error";
const assistantText = ASSISTANT_MD.slice(0, visibleLength);
const isStreaming = visibleLength < ASSISTANT_MD.length;
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
{STATES.map((state) => (
<Button
key={state}
type="button"
variant={recipeState === state ? "default" : "outline"}
onClick={() => {
setIsRetrying(false);
setRecipeState(state);
if (state === "populated") {
setRunId((n) => n + 1);
}
}}
>
{state}
</Button>
))}
{recipeState === "populated" ? (
<button
type="button"
className="text-muted-foreground self-center text-xs underline"
onClick={() => setRunId((n) => n + 1)}
>
Replay stream
</button>
) : null}
</div>
<MessageScrollerProvider className="h-[380px]">
<MessageScroller className="h-full rounded-xl border border-border">
<MessageScrollerViewport>
{!hasUsableMessages && isPending ? (
<MessageScrollerContent mode="state" className="p-4">
<MessageScrollerLoading />
</MessageScrollerContent>
) : null}
{!hasUsableMessages && threadError ? (
<MessageScrollerContent mode="state" className="p-4">
<MessageScrollerError
isRetrying={isRetrying}
onRetry={() => {
setIsRetrying(true);
window.setTimeout(() => {
setIsRetrying(false);
}, 800);
}}
/>
</MessageScrollerContent>
) : null}
{!hasUsableMessages && isSuccess ? (
<MessageScrollerContent mode="state" className="p-4">
<MessageScrollerEmpty
suggestions={
<>
<Button type="button" variant="outline">
Summarize this doc
</Button>
<Button type="button" variant="outline">
Draft a reply
</Button>
</>
}
/>
</MessageScrollerContent>
) : null}
{hasUsableMessages ? (
<MessageScrollerContent className="p-4">
{messages.map((msg) => (
<MessageScrollerItem key={msg.id} messageId={msg.id}>
<Message align="end">
<MessageContent>
<Bubble>
<BubbleContent>
<p className="whitespace-pre-wrap">{msg.text}</p>
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
<MessageScrollerItem messageId="a1">
<Message align="start">
<MessageContent>
<MarkdownRenderer
className="prose-sm dark:prose-invert max-w-none"
source={assistantText}
isStreaming={isStreaming}
/>
</MessageContent>
</Message>
</MessageScrollerItem>
</MessageScrollerContent>
) : null}
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>
);
}Loading Transcript
Initial hydrate with no usable messages yet. Mount MessageScrollerLoading inside MessageScrollerContent mode="state" so the skeleton fills the scrollport without log live-region semantics.
"use client";
import {
MessageScroller,
MessageScrollerContent,
MessageScrollerLoading,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/f-ui/message-scroller/message-scroller";
export function MessageScrollerLoadingDemo() {
return (
<MessageScrollerProvider className="h-[320px]">
<MessageScroller className="h-full rounded-xl border border-border">
<MessageScrollerViewport>
<MessageScrollerContent mode="state" className="p-4">
<MessageScrollerLoading />
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</MessageScrollerProvider>
);
}Custom Loading Skeleton
Pass renderRow to swap each default bubble placeholder, or children for a full inner takeover that still keeps the busy status shell.
"use client";
import { Skeleton } from "@/components/ui/skeleton";
import {
MessageScroller,
MessageScrollerContent,
MessageScrollerLoading,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/f-ui/message-scroller/message-scroller";
export function MessageScrollerLoadingCustomDemo() {
return (
<MessageScrollerProvider className="h-[320px]">
<MessageScroller className="h-full rounded-xl border border-border">
<MessageScrollerViewport>
<MessageScrollerContent mode="state" className="p-4">
<MessageScrollerLoading
rows={4}
renderRow={(i) => {
const widths = ["max-w-md", "max-w-sm", "max-w-lg", "max-w-xs"] as const;
return (
<div className="flex w-full items-center gap-3">
<Skeleton className="bg-muted-foreground/15 size-8 shrink-0 rounded-full" />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Skeleton className="bg-muted-foreground/15 h-3 w-1/3" />
<Skeleton
className={`bg-muted-foreground/15 h-8 w-full rounded-xl ${widths[i % widths.length]}`}
/>
</div>
</div>
);
}}
/>
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</MessageScrollerProvider>
);
}Empty Conversation
A successful empty thread. Default welcome copy plus host-owned suggestion pills so the pane is never blank after a successful empty response.
"use client";
import { Button } from "@/components/ui/button";
import {
MessageScroller,
MessageScrollerContent,
MessageScrollerEmpty,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/f-ui/message-scroller/message-scroller";
export function MessageScrollerEmptyDemo() {
return (
<MessageScrollerProvider className="h-[320px]">
<MessageScroller className="h-full rounded-xl border border-border">
<MessageScrollerViewport>
<MessageScrollerContent mode="state" className="p-4">
<MessageScrollerEmpty
suggestions={
<>
<Button type="button" variant="outline">
Summarize this doc
</Button>
<Button type="button" variant="outline">
Draft a reply
</Button>
</>
}
/>
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</MessageScrollerProvider>
);
}Thread Load Error
Initial thread failure with fill MessageScrollerError and Retry. Recovery is required (onRetry or a custom action).
"use client";
import { useState } from "react";
import {
MessageScroller,
MessageScrollerContent,
MessageScrollerError,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/f-ui/message-scroller/message-scroller";
export function MessageScrollerErrorDemo() {
const [isRetrying, setIsRetrying] = useState(false);
return (
<MessageScrollerProvider className="h-[320px]">
<MessageScroller className="h-full rounded-xl border border-border">
<MessageScrollerViewport>
<MessageScrollerContent mode="state" className="p-4">
<MessageScrollerError
isRetrying={isRetrying}
onRetry={() => {
setIsRetrying(true);
window.setTimeout(() => {
setIsRetrying(false);
}, 800);
}}
/>
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</MessageScrollerProvider>
);
}Turn Generation Error
A populated transcript where one assistant turn failed. MessageTurnError sits inside the item (mutually exclusive with that turn’s Generating Marker); Retry is focusable recovery.
"use client";
import { useState } from "react";
import { Bubble, BubbleContent } from "@/components/f-ui/bubble/bubble";
import {
Message,
MessageContent,
} from "@/components/f-ui/message/message";
import {
MessageScroller,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
MessageTurnError,
} from "@/components/f-ui/message-scroller/message-scroller";
export function MessageScrollerTurnErrorDemo() {
const [isRetrying, setIsRetrying] = useState(false);
return (
<MessageScrollerProvider className="h-[320px]">
<MessageScroller className="h-full rounded-xl border border-border">
<MessageScrollerViewport>
<MessageScrollerContent className="p-4">
<MessageScrollerItem messageId="u1">
<Message align="end">
<MessageContent>
<Bubble>
<BubbleContent>
<p className="whitespace-pre-wrap">
Summarize the latest release notes.
</p>
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
<MessageScrollerItem messageId="a1">
<Message align="start">
<MessageContent>
<MessageTurnError
isRetrying={isRetrying}
onRetry={() => {
setIsRetrying(true);
window.setTimeout(() => {
setIsRetrying(false);
}, 800);
}}
/>
</MessageContent>
</Message>
</MessageScrollerItem>
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</MessageScrollerProvider>
);
}Headless Usage
useMessageScroller is the v1 view-model: scroll commands (scrollToEnd, scrollToStart, scrollToMessage) plus isAtBottom. It must run under MessageScrollerProvider. MessageScrollerViewport and MessageScrollerContent remain required for stick-to-bottom — they bind the engine’s scrollRef / contentRef. Do not omit them unless you own those refs yourself (fork the engine wiring).
useMessageScroller is the view-model: scroll helpers and isAtBottom. Pair it with your own viewport markup under MessageScrollerProvider.
isAtBottom: true
"use client";
import {
MessageScrollerProvider,
useMessageScroller,
} from "@/components/f-ui/message-scroller/message-scroller";
function HeadlessControls() {
const { isAtBottom, scrollToEnd, scrollToStart } = useMessageScroller();
return (
<div>
<p>isAtBottom: {String(isAtBottom)}</p>
<button type="button" onClick={() => void scrollToStart()}>
scrollToStart
</button>{" "}
<button type="button" onClick={() => void scrollToEnd()}>
scrollToEnd
</button>
</div>
);
}
export function MessageScrollerHeadlessDemo() {
return (
<MessageScrollerProvider>
<p>
useMessageScroller is the view-model: scroll helpers and isAtBottom.
Pair it with your own viewport markup under MessageScrollerProvider.
</p>
<HeadlessControls />
</MessageScrollerProvider>
);
}Composition
MessageScrollerProvider
└─ (app chrome)
├─ MessageScroller
│ ├─ MessageScrollerViewport
│ │ ├─ MessageScrollerError variant="inline" ← optional; before Content on refresh fail
│ │ └─ MessageScrollerContent
│ │ │ mode="state" ← Loading / Empty / fill Error takeovers
│ │ │ ├─ MessageScrollerLoading
│ │ │ ├─ MessageScrollerEmpty
│ │ │ └─ MessageScrollerError variant="fill"
│ │ │ mode="log" (default) ← populated transcript
│ │ │ ├─ MessageScrollerItem ← messageId required
│ │ │ │ └─ Message | Marker | MessageTurnError | host
│ │ │ └─ …
│ └─ MessageScrollerButton ← Jump to latest
└─ PromptComposer ← sibling; not inside ViewportOn product pages: keep Viewport full-bleed of the main column; put max-w-* measure wrappers inside Content (and mirror on the composer), not around Viewport.
Edge Cases & Errors
Pending Empty Array vs Success Empty
Chat hooks often initialize messages to []. While isPending, mount Loading — never Empty. Empty is only for a successful empty response (isSuccess and no usable messages).
Fill vs Inline Thread Error
- Fill (
variant="fill", default): exclusive child of state-mode Content when there are no usable messages (initial load failure). - Inline (
variant="inline"): sibling before log-mode Content when a background refresh fails but cached messages remain. Keep the transcript; do not swap to a full-region skeleton.
Safe Error Copy
Default titles and descriptions are localized generics. Parts never surface raw Error.message or API codes. Pass a sanitized description / message only when the host has vetted copy.
Turn Error vs Generating Marker
For a given turn, show either MessageTurnError or the Generating Marker — not both. Retry should clear the turn error, then resume the Marker / stream path.
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
autoScroll | boolean | true | Provider: when false, disables stick-follow; Jump via hook still works. |
preserveScrollOnPrepend | boolean | true | Provider: keep viewport when older items prepend. |
resize | "instant" | "smooth" | "instant" | Stick on content resize. Keep instant for streaming; smooth is unsafe for token growth. |
initial | "instant" | "smooth" | "instant" | Stick behavior on first mount. |
mode | "log" | "state" | "log" | Content: log live region for messages; state for Loading / Empty / fill Error takeovers (no role="log"). |
messageId | string | (required on Item) | Stable id for registry, jump-to-id, and prepend. |
className | string | — | On Provider (wraps children in a host chrome div; Stick only gets MessageScroller's className), Scroller, Viewport, Content, Item, Button, and async chrome parts. |
MessageScrollerViewport defaults to scrollbar-gutter: stable (not both-edges) so end-aligned user bubbles stay flush to content padding, and applies a quiet thin native scrollbar (aligned with Table). Override via Viewport style / className when needed.
MessageScrollerLoading
| Prop | Type | Default | Description |
|---|---|---|---|
rows | number | 3 | Skeleton row count (alternating assistant / user widths). Ignored when children is set. |
variant | "skeleton" | "spinner" | "skeleton" | Default chrome when children is omitted. |
renderRow | (index: number) => ReactNode | — | Custom skeleton row body; status shell and row count stay. Ignored when children is set. |
children | ReactNode | — | Full inner takeover; keeps outer status / aria-busy wrapper. |
className | string | — | Root class. |
MessageScrollerEmpty
| Prop | Type | Default | Description |
|---|---|---|---|
icon / illustration | ReactNode | muted icon | Leading visual; illustration wins when both set. |
title / description | ReactNode | i18n defaults | Welcome headline and supporting line. |
action | ReactNode | — | Primary next action. |
suggestions | ReactNode | — | Host-owned suggestion row (e.g. outline pills). |
children | ReactNode | — | Full inner takeover; keeps outer status wrapper. |
className | string | — | Root class. |
MessageScrollerError
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "fill" | "inline" | "fill" | Centered takeover vs compact banner above retained messages. |
title / description | ReactNode | i18n defaults | Safe generic copy; never derived from thrown errors. |
onRetry / isRetrying | () => void / boolean | — | Convenience Retry (mutually exclusive with action). |
action | ReactNode | — | Custom recovery control when not using onRetry. |
className | string | — | Root class. |
Exactly one recovery contract is required: onRetry or action.
MessageTurnError
| Prop | Type | Default | Description |
|---|---|---|---|
message | ReactNode | i18n default | Turn-level failure copy (role="group"). |
onRetry / isRetrying | () => void / boolean | — | Convenience Retry (mutually exclusive with action). |
action | ReactNode | — | Custom recovery control when not using onRetry. |
className | string | — | Root class. |
Exactly one recovery contract is required: onRetry or action. Do not mount beside a Generating Marker for the same turn.
Slots
Parts: MessageScrollerProvider, MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton, MessageScrollerLoading, MessageScrollerEmpty, MessageScrollerError, MessageTurnError.
Hook
useMessageScroller() returns { scrollToEnd, scrollToStart, scrollToMessage, isAtBottom }. Throws outside Provider. Hosts also export compensateScrollTopAfterPrepend(el, prevScrollHeight) for explicit load-older flows.