Siteplane UI

Message Scroller

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

Markdown

Northlight AI can make mistakes. Check important information.

"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

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

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

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

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

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 and Bubble. The rows sit 32 px apart. MessageScroller is a 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 until its first words arrive and then streams in, and Stop in the 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.

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

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

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

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 SDKMessage Scroller
message.idkey 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 stopthe Prompt Input below
"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 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 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.

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.

const { messages, setMessages } = useChat();

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

API reference

Component properties
PropTypeDefaultDescription
MessageScroller.autoScrollbooleanfalseFollows 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.scrollEdgeThresholdnumber8How close to an edge, in pixels, counts as being at it.
MessageScroller.scrollPreviousItemPeeknumber64How much of the row before a new turn stays in view above it, in pixels.
MessageScroller.preserveScrollOnPrependbooleantrueKeeps the row the reader looks at in place when rows are added above the first one.
MessageScroller.labelsPartial<MessageScrollerLabels>EnglishReplaces the words: region (Messages), scrollToEnd (Scroll to latest), scrollToStart (Scroll to first), responding (Assistant is responding) and complete (Response complete).
MessageScrollerContent.busybooleanfalseMarks 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.messageIdstringrequiredNames the row, to scroll to it and to keep it in place. Keep it stable, such as the id of the message.
MessageScrollerItem.scrollAnchorbooleanfalseThe 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()MessageScrollerApiinside MessageScrollerscrollToEnd, scrollToStart and scrollToMessage(messageId), each with { behavior }, and scrollable: { start, end }, the edges the viewport can still scroll toward.

MessageScrollerProps takes the 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 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.

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.

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.