f-ui
Components

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

AreaBehavior
Stick-to-bottomEngine 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 gateautoScroll={false} disconnects resize follow; manual scroll helpers still work
JumpMessageScrollerButton — circular ArrowDown when not at bottom (aria-label i18n; override with children)
PrependMount heuristic + compensateScrollTopAfterPrepend for host-controlled loads
ItemsEvery row under Content must use MessageScrollerItem with messageId
Content modeDefault mode="log" is a live region; mode="state" for exclusive Loading / Empty / fill Error takeovers
LoadingMessageScrollerLoading — default skeleton / spinner, or renderRow / children customization inside state-mode Content
EmptyMessageScrollerEmpty — welcome copy, optional action / suggestions / children takeover
ErrorMessageScrollerError — fill (initial) or inline (background refresh); MessageTurnError for per-turn recovery
ComposerSibling 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; MessageScrollerButton jumps 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.length changes (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" or scrollTo({ behavior: "smooth" }) during token streams (lags behind variable-size growth).
  • Unlock stick from bare scroll events without distinguishing content growth from user scroll-up.
  • Timeout-based “user is scrolling” flags or polling scrollTop on 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-scroller
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/message-scroller
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/message-scroller
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/message-scroller

registryDependencies: 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.

Can you summarize how stick-to-bottom should feel during a long reply?

While tokens stream, the transcript should stay pinned to the newest line so you do not chase the caret.

What if I scroll up to read an earlier turn?

Auto-follow pauses. A quiet Jump to latest control appears so you can rejoin the live edge when ready.

Show me stick-to-bottom while streaming.

"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.

Show the assistant-first markdown layout.

"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.

Loading messages
"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.

Loading messages
"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.

Start a conversation

Send a message to begin

"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.

Summarize the latest release notes.

Couldn't generate a response

"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 Viewport

On 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

PropTypeDefaultDescription
autoScrollbooleantrueProvider: when false, disables stick-follow; Jump via hook still works.
preserveScrollOnPrependbooleantrueProvider: 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").
messageIdstring(required on Item)Stable id for registry, jump-to-id, and prepend.
classNamestringOn 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

PropTypeDefaultDescription
rowsnumber3Skeleton row count (alternating assistant / user widths). Ignored when children is set.
variant"skeleton" | "spinner""skeleton"Default chrome when children is omitted.
renderRow(index: number) => ReactNodeCustom skeleton row body; status shell and row count stay. Ignored when children is set.
childrenReactNodeFull inner takeover; keeps outer status / aria-busy wrapper.
classNamestringRoot class.

MessageScrollerEmpty

PropTypeDefaultDescription
icon / illustrationReactNodemuted iconLeading visual; illustration wins when both set.
title / descriptionReactNodei18n defaultsWelcome headline and supporting line.
actionReactNodePrimary next action.
suggestionsReactNodeHost-owned suggestion row (e.g. outline pills).
childrenReactNodeFull inner takeover; keeps outer status wrapper.
classNamestringRoot class.

MessageScrollerError

PropTypeDefaultDescription
variant"fill" | "inline""fill"Centered takeover vs compact banner above retained messages.
title / descriptionReactNodei18n defaultsSafe generic copy; never derived from thrown errors.
onRetry / isRetrying() => void / booleanConvenience Retry (mutually exclusive with action).
actionReactNodeCustom recovery control when not using onRetry.
classNamestringRoot class.

Exactly one recovery contract is required: onRetry or action.

MessageTurnError

PropTypeDefaultDescription
messageReactNodei18n defaultTurn-level failure copy (role="group").
onRetry / isRetrying() => void / booleanConvenience Retry (mutually exclusive with action).
actionReactNodeCustom recovery control when not using onRetry.
classNamestringRoot 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.

On this page