# Message Scroller

Scroll a chat thread that opens at the latest message, follows a streaming reply and leads back to the latest message.

Chat thread with steps, suggestions and a message field

[Complete TSX file](/examples/message-scroller/default.tsx)

```tsx
"use client";

import {
  BrainIcon,
  ChevronDownIcon,
  MessageSquareIcon,
  RefreshCwIcon,
  SearchIcon,
  ThumbsDownIcon,
  ThumbsUpIcon,
} from "lucide-react";
import * as React from "react";

import { Bubble, BubbleContent } from "@/siteplane/components/bubble";
import { Button } from "@/siteplane/components/button";
import { CopyButton } from "@/siteplane/components/copy-button";
import {
  Marker,
  MarkerContent,
  MarkerIcon,
} from "@/siteplane/components/marker";
import {
  Menu,
  MenuGroup,
  MenuGroupLabel,
  MenuPopup,
  MenuRadioGroup,
  MenuRadioItem,
  MenuTrigger,
} from "@/siteplane/components/menu";
import {
  Message,
  MessageContent,
  MessageFooter,
} from "@/siteplane/components/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
} from "@/siteplane/components/message-scroller";
import { MotionShimmer } from "@/siteplane/components/motion";
import {
  PromptInput,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputToolbar,
  PromptInputToolbarGroup,
} from "@/siteplane/components/prompt-input";
import { Spinner } from "@/siteplane/components/spinner";
import {
  Tooltip,
  TooltipPopup,
  TooltipTrigger,
} from "@/siteplane/components/tooltip";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/siteplane/components/trace";

type ChatStatus = "ready" | "submitted" | "streaming";

type Turn = {
  id: string;
  role: "user" | "assistant";
  /** The text the turn shows so far. */
  text: string;
  /** The whole reply, which streams into `text`. */
  reply?: string;
  /** Whether the turn shows the steps that led to its reply. */
  steps?: boolean;
  /** Whether the reader stopped the reply before it was done. */
  stopped?: boolean;
};

const reasoning =
  "Churn shows up as falling activity and questions nobody answered. I'll compare each account's weekly active seats over the last 12 weeks with its plan, then read the open support conversations of the accounts that dropped most.";

const answer =
  "Three accounts dropped below half of their usual activity in the last six weeks, and each has a support conversation that has waited more than a week for a reply. I drafted a short check-in for each, offering a call this week and answering their open question.";

const replies: Record<string, string> = {
  "Make the emails friendlier":
    "I rewrote the openings to sound more personal. Each email now asks how the team is doing, names their open question and offers two times for a call this week. The drafts are ready for you to review.",
  "Show all nine accounts":
    "Nine accounts lost 40 percent or more of their active seats in 12 weeks. Besides Halden Logistics, Brightwave Studio and Ortega Foods, they are Fenwick Partners, Lumen Health, Arbor Freight, Kitebox, Meridian Labs and Solano Group. Four of the six have no open support conversation, so a check-in from their account owner is the better first step.",
  "Book a call with Halden":
    "I added a call to the Halden Logistics draft for Thursday at 10:00 with Maya Chen, their account owner, and a link to pick another time. The invitation goes out when you send the email.",
};

const fallbackReply =
  "This example answers with prepared text. Connect the thread to your chat API to answer other questions.";

const suggestions = Object.keys(replies);

const models = ["Fast", "Thorough"];

/**
 * A stand-in for your chat hook, such as useChat of the AI SDK. A reply waits
 * a moment, as a model does before its first words, and then streams in.
 */
function useScriptedChat() {
  const [turns, setTurns] = React.useState<Turn[]>([
    {
      id: "question",
      role: "user",
      text: "Which accounts are likely to churn this month? Draft a check-in for the top three.",
    },
    {
      id: "answer",
      reply: answer,
      role: "assistant",
      steps: true,
      text: answer,
    },
  ]);
  const [status, setStatus] = React.useState<ChatStatus>("ready");
  const [activeId, setActiveId] = React.useState<string | null>(null);
  const timer = React.useRef<ReturnType<typeof setInterval>>(undefined);
  const count = React.useRef(0);

  const halt = React.useCallback(() => clearInterval(timer.current), []);
  React.useEffect(() => halt, [halt]);

  function update(id: string, change: Partial<Turn>) {
    setTurns((all) =>
      all.map((turn) => (turn.id === id ? { ...turn, ...change } : turn)),
    );
  }

  function play(id: string, reply: string) {
    halt();
    const words = reply.split(" ");
    let tick = 0;
    setActiveId(id);
    setStatus("submitted");
    timer.current = setInterval(() => {
      tick += 1;
      if (tick < 15) return;
      const shown = Math.min(words.length, (tick - 14) * 2);
      setStatus("streaming");
      update(id, { text: words.slice(0, shown).join(" ") });
      if (shown === words.length) {
        halt();
        setActiveId(null);
        setStatus("ready");
      }
    }, 60);
  }

  function send(text: string) {
    if (status !== "ready") return;
    count.current += 1;
    const id = `turn-${count.current}`;
    const reply = replies[text] ?? fallbackReply;
    setTurns((all) => [
      ...all,
      { id: `${id}-question`, role: "user", text },
      { id: `${id}-answer`, reply, role: "assistant", text: "" },
    ]);
    play(`${id}-answer`, reply);
  }

  function regenerate(id: string) {
    const turn = turns.find((item) => item.id === id);
    if (status !== "ready" || !turn?.reply) return;
    update(id, { stopped: false, text: "" });
    play(id, turn.reply);
  }

  function stop() {
    halt();
    if (activeId) update(activeId, { stopped: true });
    setActiveId(null);
    setStatus("ready");
  }

  return { activeId, regenerate, send, status, stop, turns };
}

function UserTurn({ text }: { text: string }) {
  return (
    <Message align="end">
      <MessageContent>
        <Bubble align="end" variant="muted">
          <BubbleContent>{text}</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  );
}

/** The reasoning and the tool calls behind the first reply. */
function Steps() {
  return (
    <Trace>
      <TraceCollapsible>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <BrainIcon />
            </TraceIcon>
            <TraceTitle>Thought</TraceTitle>
            <TraceDescription>6s</TraceDescription>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <p className="text-sm text-muted-foreground">{reasoning}</p>
        </TraceCollapsibleContent>
      </TraceCollapsible>
      <TraceLine>
        <TraceIcon>
          <SearchIcon />
        </TraceIcon>
        <TraceTitle>Searched 214 accounts</TraceTitle>
      </TraceLine>
      <TraceLine>
        <TraceIcon>
          <MessageSquareIcon />
        </TraceIcon>
        <TraceTitle>Read 38 conversations</TraceTitle>
      </TraceLine>
    </Trace>
  );
}

function Action({
  children,
  disabled,
  label,
  onClick,
  pressed,
}: {
  children: React.ReactNode;
  disabled?: boolean;
  label: string;
  onClick?: () => void;
  pressed?: boolean;
}) {
  return (
    <Tooltip>
      <TooltipTrigger
        render={
          <Button
            aria-label={label}
            aria-pressed={pressed}
            disabled={disabled}
            onClick={onClick}
            profile="inline"
            profileTone="muted"
            shape="square"
            size="icon-xs"
            variant="text"
          >
            {children}
          </Button>
        }
      />
      <TooltipPopup aria-hidden>{label}</TooltipPopup>
    </Tooltip>
  );
}

function ReplyActions({
  onRetry,
  retryDisabled,
  text,
}: {
  onRetry: () => void;
  retryDisabled: boolean;
  text: string;
}) {
  const [feedback, setFeedback] = React.useState<"good" | "bad" | null>(null);
  return (
    <MessageFooter>
      <CopyButton labels={{ copy: "Copy response" }} value={text} />
      <Action
        label="Good response"
        onClick={() => setFeedback(feedback === "good" ? null : "good")}
        pressed={feedback === "good"}
      >
        <ThumbsUpIcon
          aria-hidden="true"
          fill={feedback === "good" ? "currentColor" : "none"}
        />
      </Action>
      <Action
        label="Bad response"
        onClick={() => setFeedback(feedback === "bad" ? null : "bad")}
        pressed={feedback === "bad"}
      >
        <ThumbsDownIcon
          aria-hidden="true"
          fill={feedback === "bad" ? "currentColor" : "none"}
        />
      </Action>
      <Action disabled={retryDisabled} label="Try again" onClick={onRetry}>
        <RefreshCwIcon aria-hidden="true" />
      </Action>
    </MessageFooter>
  );
}

function AssistantTurn({
  onRetry,
  phase,
  retryDisabled,
  turn,
}: {
  onRetry: () => void;
  phase: "submitted" | "streaming" | "done";
  retryDisabled: boolean;
  turn: Turn;
}) {
  return (
    // A reply with steps keeps 16 px between its blocks; the steps keep 8 px.
    <Message
      className={
        turn.steps ? "[--sp-message-gap:var(--sp-rw-space-4)]" : undefined
      }
    >
      <MessageContent>
        {turn.steps ? <Steps /> : null}
        {phase === "submitted" ? (
          <Marker role="status">
            <MarkerIcon>
              <Spinner />
            </MarkerIcon>
            <MarkerContent>
              <MotionShimmer>Thinking</MotionShimmer>
            </MarkerContent>
          </Marker>
        ) : null}
        {turn.text ? (
          <Bubble variant="ghost">
            <BubbleContent>{turn.text}</BubbleContent>
          </Bubble>
        ) : null}
        {turn.stopped ? (
          <Marker role="status">
            <MarkerContent>You stopped the response.</MarkerContent>
          </Marker>
        ) : null}
        {phase === "done" && turn.text ? (
          <ReplyActions
            onRetry={onRetry}
            retryDisabled={retryDisabled}
            text={turn.text}
          />
        ) : null}
      </MessageContent>
    </Message>
  );
}

function ModelMenu() {
  const [model, setModel] = React.useState("Thorough");
  return (
    <Menu>
      <MenuTrigger
        render={
          <Button aria-label={`Model: ${model}`} size="sm" variant="ghost" />
        }
      >
        {model}
        <ChevronDownIcon />
      </MenuTrigger>
      <MenuPopup align="end" side="top">
        <MenuGroup>
          <MenuGroupLabel>Model</MenuGroupLabel>
          <MenuRadioGroup
            onValueChange={(value) => setModel(String(value))}
            value={model}
          >
            {models.map((name) => (
              <MenuRadioItem closeOnClick key={name} value={name}>
                {name}
              </MenuRadioItem>
            ))}
          </MenuRadioGroup>
        </MenuGroup>
      </MenuPopup>
    </Menu>
  );
}

export default function Example() {
  const chat = useScriptedChat();
  const busy = chat.status !== "ready";

  return (
    <div className="flex h-[40rem] w-full max-w-2xl flex-col overflow-hidden rounded-xl border">
      <MessageScroller autoScroll className="min-h-0 flex-1">
        <MessageScrollerContent busy={busy} className="px-6 py-8">
          {chat.turns.map((turn) => (
            <MessageScrollerItem
              key={turn.id}
              messageId={turn.id}
              scrollAnchor={turn.role === "user"}
            >
              {turn.role === "user" ? (
                <UserTurn text={turn.text} />
              ) : (
                <AssistantTurn
                  onRetry={() => chat.regenerate(turn.id)}
                  phase={
                    turn.id === chat.activeId && chat.status !== "ready"
                      ? chat.status
                      : "done"
                  }
                  retryDisabled={busy}
                  turn={turn}
                />
              )}
            </MessageScrollerItem>
          ))}
        </MessageScrollerContent>
        <MessageScrollerButton />
      </MessageScroller>
      <div className="flex shrink-0 flex-col gap-4 px-6 pb-4">
        <div className="flex flex-wrap gap-2">
          {suggestions.map((suggestion) => (
            <Button
              disabled={busy}
              key={suggestion}
              onClick={() => chat.send(suggestion)}
              shape="pill"
              size="sm"
              variant="outline"
            >
              {suggestion}
            </Button>
          ))}
        </div>
        <div className="flex flex-col gap-2">
          <PromptInput
            onStop={chat.stop}
            onSubmit={({ text }) => chat.send(text)}
            status={chat.status}
          >
            <PromptInputTextarea placeholder="Ask anything about your workspace" />
            <PromptInputToolbar>
              <PromptInputToolbarGroup align="end">
                <ModelMenu />
                <PromptInputSubmit />
              </PromptInputToolbarGroup>
            </PromptInputToolbar>
          </PromptInput>
          <p className="text-center text-xs text-muted-foreground">
            Northlight AI can make mistakes. Check important information.
          </p>
        </div>
      </div>
    </div>
  );
}
```

## Installation

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/message-scroller
```

With npm: `npx shadcn@4.18.0 add @siteplane/message-scroller`. [Registry JSON](/registry/message-scroller.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/bubble @siteplane/copy-button @siteplane/marker @siteplane/menu @siteplane/message @siteplane/prompt-input @siteplane/spinner @siteplane/tooltip @siteplane/trace
```

## Usage

```tsx
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
} from "@/siteplane/components/message-scroller";

<div className="h-96">
  <MessageScroller>
    <MessageScrollerContent>
      <MessageScrollerItem messageId="question" scrollAnchor>
        Which accounts are likely to churn this month?
      </MessageScrollerItem>
    </MessageScrollerContent>
    <MessageScrollerButton />
  </MessageScroller>
</div>;
```

## Composition

```text
MessageScroller
├── MessageScrollerContent
│   └── MessageScrollerItem (one per row: a turn, a marker or a pending reply)
└── MessageScrollerButton
```

Each direct child of `MessageScrollerContent` is a `MessageScrollerItem`, with the turns themselves built from [Message](/docs/components/message/) and [Bubble](/docs/components/bubble/). The rows sit 32 px apart. `MessageScroller` is a [Scroll Area](/docs/components/scroll-area/) that fills its container, so give the container a height: a fixed one, or the free space of a flex column with `min-h-0 flex-1`, as the example at the top of this page does.

## Turns

Set `scrollAnchor` on the row that starts a turn, usually the person's message. When such a row arrives at the end, the thread scrolls it near the top of the viewport and keeps 64 px of the row before it in view (`scrollPreviousItemPeek`), so the reply has room to grow below it. Until the reply fills the viewport, the thread holds that room below the last row and gives it up as the reply grows.

A row that arrives after the thread opened fades in with a small rise and takes its full height at once, so the thread can measure it right away. Rows that are there when the thread opens, and rows added above the first one, take their place without the entrance. Text that streams into a row grows without animation.

In the example at the top of this page, a message you send or a suggestion you pick starts a turn. The reply shows a Thinking [Marker](/docs/components/marker/) until its first words arrive and then streams in, and Stop in the [Prompt Input](/docs/components/prompt-input/) ends it with a note that the response was stopped.

## Auto scroll and the scroll button

With `autoScroll`, the thread follows a growing reply while the reader is at its end, within 8 px (`scrollEdgeThreshold`). It lets go when the reader scrolls toward the start with the wheel, a touch drag or the Arrow Up, Page Up or Home key, when they press the scrollbar, and when a command jumps to a message. Scrolling back to the end, or pressing the scroll button, takes up the follow again. Without `autoScroll`, a new turn still settles near the top, and the reply grows below it without moving the viewport.

A long reply that streams in at the live edge

[Complete TSX file](/examples/message-scroller/auto-scroll.tsx)

```tsx
"use client";

import * as React from "react";

import { Bubble, BubbleContent } from "@/siteplane/components/bubble";
import { Button } from "@/siteplane/components/button";
import { Message, MessageContent } from "@/siteplane/components/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
} from "@/siteplane/components/message-scroller";

type Turn = { id: string; role: "user" | "assistant"; text: string };

function exchange(id: string, question: string, answer: string): Turn[] {
  return [
    { id: `${id}-question`, role: "user", text: question },
    { id: `${id}-answer`, role: "assistant", text: answer },
  ];
}

const earlierTurns: Turn[] = [
  exchange(
    "trials",
    "How many trials converted last month?",
    "41 of 128 trials converted, or 32 percent. That is 4 points above September.",
  ),
  exchange(
    "plans",
    "Which plan did they pick?",
    "29 chose Growth, 10 chose Scale and 2 chose Enterprise.",
  ),
  exchange(
    "lost",
    "And the ones that did not convert?",
    "Most stopped after the first week, and 61 never invited a teammate.",
  ),
].flat();

const questions: [question: string, answer: string][] = [
  [
    "Summarize this week's support tickets.",
    "Support received 128 tickets this week, up 9 percent on last week. Billing questions made up 31 percent of them, mostly about the invoice layout, and the median first response was 42 minutes, inside the one-hour target. Two themes stand out. Customers on the Team plan ask how to reassign seats, and several admins could not find where to download past invoices. A short help article for each would likely remove about a fifth of next week's volume. The three oldest open tickets are waiting on customers, so I would send each a reminder tomorrow and close them after three more days without an answer.",
  ],
  [
    "Which of those themes should we write about first?",
    "Start with the invoices. They produce the most tickets, the answer is short and the same for every customer, and the article can be linked from the invoice emails themselves, where most of the questions start. The seat reassignment article is longer and depends on a screen that is about to change, so it is better written after that release. Until then, a saved reply in the help desk covers it with two sentences on where the seat list is, a screenshot of the reassign button and a note that changes apply at the next billing date.",
  ],
  [
    "Draft the invoice article.",
    "Open Settings, choose Billing and then Invoices. Every invoice you have received is listed with its date, amount and status, and the download icon at the end of a row saves it as a PDF. If an invoice needs a different company name or tax number, update them under Billing details first, because new invoices use the details you saved. To have a past invoice reissued with the new details, reply to any billing email and the billing team sends the corrected PDF within one business day. Invoices from before your workspace moved to the current billing system are kept as well, so ask support for the month you need.",
  ],
];

function Row({ turn }: { turn: Turn }) {
  if (turn.role === "user") {
    return (
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>{turn.text}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    );
  }
  return (
    <Message>
      <MessageContent>
        <Bubble variant="ghost">
          <BubbleContent>{turn.text}</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  );
}

export default function Example() {
  const [turns, setTurns] = React.useState(earlierTurns);
  const [busy, setBusy] = React.useState(false);
  const asked = React.useRef(0);
  const timer = React.useRef<ReturnType<typeof setInterval>>(undefined);
  React.useEffect(() => () => clearInterval(timer.current), []);

  function ask() {
    const index = asked.current;
    asked.current += 1;
    const [question, answer] = questions[index % questions.length]!;
    const id = `asked-${index}`;
    const words = answer.split(" ");
    let shown = 2;
    setTurns((all) => [
      ...all,
      ...exchange(id, question, words.slice(0, shown).join(" ")),
    ]);
    setBusy(true);
    timer.current = setInterval(() => {
      shown = Math.min(words.length, shown + 2);
      const text = words.slice(0, shown).join(" ");
      setTurns((all) =>
        all.map((turn) =>
          turn.id === `${id}-answer` ? { ...turn, text } : turn,
        ),
      );
      if (shown === words.length) {
        clearInterval(timer.current);
        setBusy(false);
      }
    }, 60);
  }

  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <div className="h-80 overflow-hidden rounded-xl border">
        <MessageScroller autoScroll>
          <MessageScrollerContent busy={busy} className="p-4">
            {turns.map((turn) => (
              <MessageScrollerItem
                key={turn.id}
                messageId={turn.id}
                scrollAnchor={turn.role === "user"}
              >
                <Row turn={turn} />
              </MessageScrollerItem>
            ))}
          </MessageScrollerContent>
          <MessageScrollerButton />
        </MessageScroller>
      </div>
      <Button
        className="self-start"
        disabled={busy}
        onClick={ask}
        size="sm"
        variant="outline"
      >
        Ask the next question
      </Button>
    </div>
  );
}
```

`MessageScrollerButton` shows only while there is something to scroll toward, and appears and leaves with the kit's motion. It floats 16 px inside the edge of the scroller as a round icon button, named “Scroll to latest”. A press scrolls to the latest row. If the button had focus, focus moves to the thread as the button leaves, so a keyboard user keeps their place. With `direction="start"` the button leads to the first row instead, named “Scroll to first”; place that one before the content.

## Opening position

A thread opens at its end. Set `defaultScrollPosition="start"` to open it at the first row, or `"last-anchor"` to open it where its last turn settled, which falls back to the end when no row has `scrollAnchor` or the last turn fits in the viewport.

```tsx
<MessageScroller defaultScrollPosition="last-anchor">
  <MessageScrollerContent>{rows}</MessageScrollerContent>
</MessageScroller>
```

For a moment after it opens, the thread holds its opening position while images and fonts load, until the reader scrolls.

## Older messages

Add older messages above the first row and keep every row's `messageId` the same. The thread then moves its scroll position by the height they take, so the row the reader looks at stays where it was (`preserveScrollOnPrepend`, on by default). Rows inserted between two rows, instead of above the first one, take their place without this correction.

Older messages loaded above the thread

[Complete TSX file](/examples/message-scroller/prepend.tsx)

```tsx
"use client";

import * as React from "react";

import { Bubble, BubbleContent } from "@/siteplane/components/bubble";
import { Button } from "@/siteplane/components/button";
import { Message, MessageContent } from "@/siteplane/components/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
} from "@/siteplane/components/message-scroller";

type Turn = { id: string; role: "user" | "assistant"; text: string };

function exchange(id: string, question: string, answer: string): Turn[] {
  return [
    { id: `${id}-question`, role: "user", text: question },
    { id: `${id}-answer`, role: "assistant", text: answer },
  ];
}

/** The whole conversation, oldest first. Each id stays the same once loaded. */
const history: Turn[] = [
  exchange(
    "trials",
    "How many trials converted last month?",
    "41 of 128 trials converted, or 32 percent. That is 4 points above September.",
  ),
  exchange(
    "plans",
    "Which plan did they pick?",
    "29 chose Growth, 10 chose Scale and 2 chose Enterprise.",
  ),
  exchange(
    "lost",
    "And the ones that did not convert?",
    "Most stopped after the first week, and 61 never invited a teammate.",
  ),
  exchange(
    "nudge",
    "Draft a nudge for those 61.",
    "Here is a short note that offers a 15-minute setup call and links the guide on inviting a team.",
  ),
  exchange(
    "shorter",
    "Shorter, please.",
    "Done. It is three sentences now, with one link and one button.",
  ),
  exchange(
    "schedule",
    "Schedule it for Monday at 9:00.",
    "Scheduled for Monday at 9:00 in each recipient's time zone.",
  ),
].flat();

/** How many rows one request for older messages returns. */
const pageSize = 4;

function Row({ turn }: { turn: Turn }) {
  if (turn.role === "user") {
    return (
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>{turn.text}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    );
  }
  return (
    <Message>
      <MessageContent>
        <Bubble variant="ghost">
          <BubbleContent>{turn.text}</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  );
}

export default function Example() {
  const [count, setCount] = React.useState(pageSize);
  const [loading, setLoading] = React.useState(false);
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);
  React.useEffect(() => () => clearTimeout(timer.current), []);
  const turns = history.slice(-count);

  function loadEarlier() {
    setLoading(true);
    // Stands in for the request that fetches the previous page.
    timer.current = setTimeout(() => {
      setCount((shown) => Math.min(history.length, shown + pageSize));
      setLoading(false);
    }, 600);
  }

  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <div className="h-80 overflow-hidden rounded-xl border">
        <MessageScroller>
          <MessageScrollerButton direction="start" />
          <MessageScrollerContent className="p-4">
            {turns.map((turn) => (
              <MessageScrollerItem
                key={turn.id}
                messageId={turn.id}
                scrollAnchor={turn.role === "user"}
              >
                <Row turn={turn} />
              </MessageScrollerItem>
            ))}
          </MessageScrollerContent>
          <MessageScrollerButton />
        </MessageScroller>
      </div>
      <Button
        className="self-start"
        disabled={count >= history.length}
        loading={loading}
        onClick={loadEarlier}
        size="sm"
        variant="outline"
      >
        Load earlier messages
      </Button>
    </div>
  );
}
```

The example loads a page of four rows from a button below the thread. A thread can also load them when the reader reaches the top, which `scrollable.start` of [`useMessageScroller`](#commands) reports by turning false. The scroll button with `direction="start"` appears once there is something above.

## Announcements

`MessageScrollerContent` is a log (`role="log"` with `aria-relevant="additions"`), so screen readers read a row when it is added. Set `busy` while a reply is on its way: the log is `aria-busy` meanwhile, so screen readers can read the finished row instead of every word as it arrives. When `busy` turns on, the kit's shared polite live region says “Assistant is responding” once, and when it turns off, “Response complete”.

The viewport is a region named “Messages”. While the thread overflows, it is a tab stop, so the keyboard can scroll it. Replace the words with `labels`, or name the region with `aria-label`.

## Commands

`useMessageScroller()` gives a control inside the scroller the thread's commands and the edges it can still scroll toward, for example a menu that jumps to a question. `scrollToMessage(id)` scrolls that row near the top, with the peek of the row before it, and returns false when no row has that id. `scrollToEnd` scrolls to the latest row and follows the thread from there, and `scrollToStart` scrolls to the first row. Each takes `{ behavior }`, and a scroll is smooth unless you pass another behavior, such as `"instant"`, or reduced motion is on. The commands are stable, and a component that uses the hook renders again only when an edge becomes scrollable or stops being so.

```tsx
function JumpToQuestion({ messageId }: { messageId: string }) {
  const { scrollToMessage } = useMessageScroller();
  return (
    <Button
      onClick={() => scrollToMessage(messageId)}
      size="sm"
      variant="ghost"
    >
      Back to the question
    </Button>
  );
}
```

The root carries `data-scrollable` with the edges it can scroll toward (`start`, `end` or `start end`), and `data-autoscrolling` while a smooth scroll of its own runs.

## AI SDK

The parts use the AI SDK’s words for statuses, so a thread built on `useChat` from `@ai-sdk/react` maps to them one to one. The examples on this page run on a scripted stand-in with the same words, so they need no model; the code below maps the AI SDK’s messages instead.

### Messages and status

| AI SDK                                     | Message Scroller                                                |
| ------------------------------------------ | --------------------------------------------------------------- |
| `message.id`                               | `key` and `messageId` of the message’s `MessageScrollerItem`    |
| `message.role === "user"`                  | `scrollAnchor`, so the person’s message starts a turn           |
| `status` is `"submitted"` or `"streaming"` | `busy` on `MessageScrollerContent`                              |
| `status`, `sendMessage` and `stop`         | the [Prompt Input](/docs/components/prompt-input/#ai-sdk) below |

```tsx
"use client";

import { useChat } from "@ai-sdk/react";

import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
} from "@/siteplane/components/message-scroller";
import {
  PromptInput,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputToolbar,
  PromptInputToolbarGroup,
} from "@/siteplane/components/prompt-input";

export function Chat() {
  const { messages, sendMessage, status, stop } = useChat();
  const busy = status === "submitted" || status === "streaming";

  return (
    <div className="flex h-dvh flex-col">
      <MessageScroller autoScroll className="min-h-0 flex-1">
        <MessageScrollerContent busy={busy} className="px-6 py-8">
          {messages.map((message, index) => (
            <MessageScrollerItem
              key={message.id}
              messageId={message.id}
              scrollAnchor={message.role === "user"}
            >
              <ChatMessage
                message={message}
                streaming={
                  status === "streaming" && index === messages.length - 1
                }
              />
            </MessageScrollerItem>
          ))}
          {status === "submitted" ? <PendingReply /> : null}
        </MessageScrollerContent>
        <MessageScrollerButton />
      </MessageScroller>
      <div className="px-6 pb-4">
        <PromptInput
          onStop={stop}
          onSubmit={({ text }) => sendMessage({ text })}
          status={status}
        >
          <PromptInputTextarea />
          <PromptInputToolbar>
            <PromptInputToolbarGroup align="end">
              <PromptInputSubmit />
            </PromptInputToolbarGroup>
          </PromptInputToolbar>
        </PromptInput>
      </div>
    </div>
  );
}
```

`ChatMessage` renders one message with Message and Bubble, as the [Message](/docs/components/message/#ai-sdk) page shows. Only the last message streams, and only while `status` is `streaming`, so its actions wait until then.

### The pending reply

While `status` is `submitted`, the reply has no message yet. Show a row of its own after the messages, with the Thinking [Marker](/docs/components/marker/) in a Message at the start side, where the reply will appear. When the first part arrives, the reply’s own row takes its place and fades in.

```tsx
import {
  Marker,
  MarkerContent,
  MarkerIcon,
} from "@/siteplane/components/marker";
import { Message, MessageContent } from "@/siteplane/components/message";
import { MessageScrollerItem } from "@/siteplane/components/message-scroller";
import { MotionShimmer } from "@/siteplane/components/motion";
import { Spinner } from "@/siteplane/components/spinner";

export function PendingReply() {
  return (
    <MessageScrollerItem messageId="pending">
      <Message>
        <MessageContent>
          <Marker role="status">
            <MarkerIcon>
              <Spinner />
            </MarkerIcon>
            <MarkerContent>
              <MotionShimmer>Thinking</MotionShimmer>
            </MarkerContent>
          </Marker>
        </MessageContent>
      </Message>
    </MessageScrollerItem>
  );
}
```

### Loading older messages

Load older messages from your own store and put them before the others with `setMessages`. Their ids stay the same, so the rows already in view keep their place.

```tsx
const { messages, setMessages } = useChat();

async function loadEarlier() {
  const older = await fetchEarlierMessages(messages[0]?.id);
  setMessages([...older, ...messages]);
}
```

## API reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| MessageScroller.autoScroll | boolean | false | Follows the thread as it grows while the reader is at its end. Scrolling toward the start or a jump to a message lets go; returning to the end or the scroll button takes it up again. |
| MessageScroller.defaultScrollPosition | "end" \| "start" \| "last-anchor" | "end" | Where the thread opens. last-anchor opens where the last turn settled and falls back to the end when no row has scrollAnchor or the turn fits. |
| MessageScroller.scrollEdgeThreshold | number | 8 | How close to an edge, in pixels, counts as being at it. |
| MessageScroller.scrollPreviousItemPeek | number | 64 | How much of the row before a new turn stays in view above it, in pixels. |
| MessageScroller.preserveScrollOnPrepend | boolean | true | Keeps the row the reader looks at in place when rows are added above the first one. |
| MessageScroller.labels | Partial<MessageScrollerLabels> | English | Replaces the words: region (Messages), scrollToEnd (Scroll to latest), scrollToStart (Scroll to first), responding (Assistant is responding) and complete (Response complete). |
| MessageScrollerContent.busy | boolean | false | Marks the log aria-busy while a reply is on its way, and says once that the assistant is responding and once that the response is complete. |
| MessageScrollerItem.messageId | string | required | Names the row, to scroll to it and to keep it in place. Keep it stable, such as the id of the message. |
| MessageScrollerItem.scrollAnchor | boolean | false | The row starts a turn: when it arrives at the end, it settles near the top with the peek of the row before it. |
| MessageScrollerButton.direction | "end" \| "start" | "end" | Scrolls to the latest row or to the first one. The button shows only while there is something to scroll toward. |
| useMessageScroller() | MessageScrollerApi | inside MessageScroller | scrollToEnd, scrollToStart and scrollToMessage(messageId), each with { behavior }, and scrollable: { start, end }, the edges the viewport can still scroll toward. |
`MessageScrollerProps` takes the [Scroll Area](/docs/components/scroll-area/)
props except `viewportRef`. `ref` goes to the root, and `aria-label` or
`aria-labelledby` names the region in place of `labels.region`.
`MessageScrollerContent` and `MessageScrollerItem` accept the props of a div.
`MessageScrollerButton` takes the [Button](/docs/components/button/) props
except `children`: it is a round icon Button at the provider's control size,
named by `labels.scrollToEnd` or `labels.scrollToStart` unless you pass
`aria-label`, and an `onClick` that calls `preventDefault()` keeps it from
scrolling. The types `MessageScrollerLabels` and `MessageScrollerApi` are
exported with the props types.

[Read the installed source](/source/message-scroller).

## Usage notes

The scroller measures only the `MessageScrollerItem` children of `MessageScrollerContent`, so render each row as one of them and do not wrap rows in another element. Give every row a stable `messageId` and use it as the React `key` too. With an index as the key, rows added above would look like rows added at the end, and the thread could not keep the reader's place.

Rows are not virtualized. Every row stays mounted, and rows outside the viewport skip their rendering work until they come into view. Load a long history in pages, as in [Older messages](#older-messages).

The row entrance and every scroll are instant when the system asks for reduced motion, when `html` carries `data-motion-reduce="true"`, or inside `SiteplaneMotionProvider preset="reduced"`. Screen readers treat `aria-busy` differently, so check how a streaming reply is announced with the screen readers your users rely on.
