Thread

The scroll container — at-bottom detection, auto-follow, docked-composer measurement, and prepend-aware restoration.

What's the difference between useMemo and useCallback?
useMemo caches a computed value; useCallback caches a function reference. In fact useCallback(fn, deps) is just useMemo(() => fn, deps).
So when do I actually need useCallback?
Mainly when you pass a callback to a memo-wrapped child or as another hook's dependency — a fresh function each render would break their memoization. Otherwise you usually don't.
"use client";

import { Composer, type ComposerSubmitData } from "@intentface/chat/composer";
import { Message } from "@intentface/chat/message";
import { Thread, useThread } from "@intentface/chat/thread";
import { ArrowDown, ArrowUp } from "@keyline-icons/react";
import { useState } from "react";

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

const INITIAL: DemoMessage[] = [
  { id: "1", role: "user", text: "What's the difference between useMemo and useCallback?" },
  {
    id: "2",
    role: "assistant",
    text: "useMemo caches a computed value; useCallback caches a function reference. In fact useCallback(fn, deps) is just useMemo(() => fn, deps).",
  },
  { id: "3", role: "user", text: "So when do I actually need useCallback?" },
  {
    id: "4",
    role: "assistant",
    text: "Mainly when you pass a callback to a memo-wrapped child or as another hook's dependency — a fresh function each render would break their memoization. Otherwise you usually don't.",
  },
];

const REPLY = "Good question — the short answer is it depends on what you're optimizing for.";

// Thread measures its docked composer and publishes the reserve as
// --thread-overlay-bottom-height, so the scroll area never hides behind it.
// autoScroll="bottom" pins the transcript to the composer: the latest turn sits
// right above it instead of landing at the top over an empty reserve. Messages
// and composer share one centred column, narrower than the window.
export const Basic = () => {
  const [messages, setMessages] = useState<DemoMessage[]>(INITIAL);

  const handleSubmit = (data: ComposerSubmitData) => {
    if (data.kind !== "message" || !data.text.trim()) return;
    setMessages((current) => [
      ...current,
      { id: `${current.length}-u`, role: "user", text: data.text },
      { id: `${current.length}-a`, role: "assistant", text: REPLY },
    ]);
  };

  return (
    <div className="h-[440px] w-full max-w-xl overflow-hidden rounded-xl bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:bg-zinc-900">
      <Thread.Root
        autoScroll="bottom"
        className="relative flex h-full w-full overflow-hidden [--thread-overlay-top-height:1rem]"
      >
        <Thread.Viewport className="h-full w-full overflow-x-hidden overflow-y-auto outline-none [overflow-anchor:auto]">
          <div className="relative flex min-h-full w-full flex-col items-center pt-(--thread-overlay-top-height) pb-(--thread-overlay-bottom-height)">
            {/* The last child carries the auto-scroll reserve the primitive sets. */}
            <Thread.Content className="mx-auto flex min-h-full w-full max-w-lg flex-col justify-end gap-5 px-4 [&>*:last-child]:min-h-(--thread-turn-min-height,0px)">
              {messages.map((message, index) => (
                <Message.Root
                  key={message.id}
                  role={message.role}
                  isLast={index === messages.length - 1}
                  className="group flex w-full flex-col gap-1.5 data-[role=user]:items-end"
                >
                  <Message.Text className="text-sm text-zinc-700 leading-6 group-data-[role=user]:max-w-[80%] group-data-[role=user]:rounded-[20px] group-data-[role=user]:bg-white group-data-[role=user]:px-3.5 group-data-[role=user]:py-1.5 group-data-[role=user]:text-zinc-900 group-data-[role=user]:leading-6 group-data-[role=user]:shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_rgb(0_0_0/0.04)] dark:text-zinc-300 dark:group-data-[role=user]:bg-zinc-800 dark:group-data-[role=user]:text-zinc-100 dark:group-data-[role=user]:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16)]">
                    {message.text}
                  </Message.Text>
                </Message.Root>
              ))}
            </Thread.Content>
          </div>
        </Thread.Viewport>
        <Thread.Composer className="absolute inset-x-0 bottom-0 z-2 w-full">
          <div className="relative mx-auto flex w-full max-w-lg flex-col items-center px-4 pb-4">
            <ScrollButton />
            <Composer.Root onSubmit={handleSubmit} className="flex w-full flex-col">
              <Composer.Container className="cursor-text rounded-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_-1px_rgb(0_0_0/0.08),0_6px_16px_-6px_rgb(0_0_0/0.1)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.2),0_1px_2px_rgb(0_0_0/0.12),0_6px_16px_-6px_rgb(0_0_0/0.22)]">
                <Composer.Textarea className="max-h-32 min-h-12 overflow-y-auto px-2.5 pt-2.5 text-sm text-zinc-900 dark:text-zinc-100 **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-6 [&_[data-composer-editor]:focus]:outline-none">
                  <Composer.Placeholder
                    placeholder="Ask a follow-up…"
                    className="text-zinc-400 leading-6 dark:text-zinc-500"
                  />
                </Composer.Textarea>
                <Composer.Actions className="flex h-12 items-center justify-end px-2.5">
                  <Composer.Submit className="flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0169cc] bg-linear-to-b from-[oklch(57.2%_0.166_253.2)] to-[oklch(52.9%_0.173_255)] text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.28),0_0_0_1px_oklch(46.5%_0.146_254.8),0_1px_2px_rgb(1_105_204/0.35)] transition-opacity focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40">
                    <ArrowUp className="size-[15px]" />
                  </Composer.Submit>
                </Composer.Actions>
              </Composer.Container>
            </Composer.Root>
          </div>
        </Thread.Composer>
      </Thread.Root>
    </div>
  );
};

// useThread exposes the scroll state the viewport tracks; the button is yours.
const ScrollButton = () => {
  const { isAtBottom, scrollToBottom } = useThread();

  if (isAtBottom) return null;

  return (
    <button
      type="button"
      onClick={() => scrollToBottom()}
      aria-label="Scroll to latest"
      className="absolute -top-10 z-10 flex size-8 cursor-pointer items-center justify-center rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] text-zinc-500 transition-colors hover:text-zinc-900 hover:from-[#fafafa] hover:to-[#f6f6f6] dark:hover:from-[#38383b] dark:hover:to-[#313134] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-400 dark:hover:text-zinc-100"
    >
      <ArrowDown className="size-[15px]" />
    </button>
  );
};

Usage guidelines

  • Scroll surface — lands the newest turn, follows the stream while you're at the bottom, and yields the moment you scroll up.
  • Auto-scroll modes — off / bottom / jump / follow via the autoScroll prop (see below).
  • Composer inset — measures the docked composer to reserve space; the overlays fade the top and bottom edges.
  • Owns no data — you map your messages in; rows are addressable by a data-message-id attribute.
  • Cheap while streaming — no scroll handler and no re-render per token; see Why the scroll subsystem is cheap.
  • Get started — see Quick start to add the package.

Anatomy

The bare nesting — Thread provides the scroll context its parts read:

tsx
<Thread.Root>
  <Thread.Overlay />
  <Thread.Viewport>
    <Thread.Content />
  </Thread.Viewport>
  <Thread.Composer />
</Thread.Root>

A realistic surface with overlays and a message list:

tsx
<Thread.Root autoScroll="follow">
  <Thread.Overlay direction="top" />
  <Thread.Viewport>
    {turns.map((turn) => (
      <Message.Turn key={turn.key} data-message-id={turn.id}>{/* … */}</Message.Turn>
    ))}
  </Thread.Viewport>
  <Thread.Composer>
    <Composer.Root onSubmit={sendMessage}>{/* … */}</Composer.Root>
  </Thread.Composer>
  {/* Not a part: a scroll-to-bottom control is yours, built from
      `useThread()`'s `isAtBottom` and `scrollToBottom`. */}
  <ScrollToBottomButton />
  <Thread.Overlay direction="bottom" />
</Thread.Root>

Examples

Choosing an auto-scroll mode

autoScroll decides two things at once: where a new turn lands, and whether the view keeps following while text streams into it. In "follow" and "jump" the newest turn reserves a full visible area so it can land at the top; Sizing the last turn shows the markup that keeps it exact.

ValueDescription
"follow"default
Newest lands at the top; the view follows the stream (ChatGPT-style).
"bottom"
Newest lands at the bottom; the view follows the stream (Codex-style).
"jump"
Newest lands at the top; the view does not follow.
"off"
A plain scroll area — no landing, no follow, no reserve.
Why does my list re-render on every keystroke?
Because the parent holding the input state re-renders, and every child re-renders with it unless something stops the cascade.
Can I just memo the list?
You can, but only if its props keep reference identity. A fresh array or an inline callback defeats it silently.

Newest lands at the top and the view follows the stream.

"use client";

import { Composer, type ComposerSubmitData } from "@intentface/chat/composer";
import { Message } from "@intentface/chat/message";
import { Thread } from "@intentface/chat/thread";
import { ArrowUp } from "@keyline-icons/react";
import { useEffect, useRef, useState } from "react";

type Exchange = { id: string; question: string; answer: string };

const SEED: Exchange[] = [
  {
    id: "1",
    question: "Why does my list re-render on every keystroke?",
    answer:
      "Because the parent holding the input state re-renders, and every child re-renders with it unless something stops the cascade.",
  },
  {
    id: "2",
    question: "Can I just memo the list?",
    answer:
      "You can, but only if its props keep reference identity. A fresh array or an inline callback defeats it silently.",
  },
];

const MODES = ["follow", "bottom", "jump", "off"] as const;
type Mode = (typeof MODES)[number];

const CAPTIONS: Record<Mode, string> = {
  follow: "Newest lands at the top and the view follows the stream.",
  bottom: "Newest lands at the bottom and the view follows the stream.",
  jump: "Newest lands at the top; the view does not follow.",
  off: "A plain scroll area — no landing, no follow, no reserve.",
};

const PROMPT = "Is memoising the list enough?";

const REPLY =
  "Memoisation compares props, so the comparison itself has to be cheaper than the render you are avoiding. That is usually true for a list and rarely true for a single row.";

/*
 * The four modes, on the same transcript.
 *
 * `autoScroll` decides two things at once: where a new turn lands, and whether
 * the view keeps following while text streams into it. The composer comes
 * prefilled — send it in each mode and watch the difference. In `follow` and
 * `jump`, notice the space reserved beneath the newest turn: it is what lets the
 * turn land at the top.
 *
 * Each question and its reply share a Message.Turn, so the reserve the primitive
 * publishes lands on the whole turn (it goes on the last child). The docked
 * composer is measured, so the reserve fills exactly the visible area.
 *
 * Remounting on a mode change is this demo's doing, not a requirement: the key
 * forces a fresh scroll subsystem so each mode is observed from its own
 * opening position rather than wherever the last one left the scroller.
 */
export const AutoScroll = () => {
  const [mode, setMode] = useState<Mode>("follow");
  const [exchanges, setExchanges] = useState<Exchange[]>(SEED);
  const [draft, setDraft] = useState(PROMPT);
  const [streaming, setStreaming] = useState(false);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);

  useEffect(() => () => timers.current.forEach(clearTimeout), []);

  const handleSubmit = (data: ComposerSubmitData) => {
    if (data.kind !== "message" || !data.text.trim() || streaming) return;
    timers.current.forEach(clearTimeout);
    timers.current = [];

    const id = `${Date.now()}`;
    setExchanges((current) => [...current, { id, question: data.text, answer: "" }]);
    setStreaming(true);

    // Word by word, so following is something you can actually watch.
    const words = REPLY.split(" ");
    words.forEach((_, index) => {
      timers.current.push(
        setTimeout(() => {
          setExchanges((current) =>
            current.map((exchange) =>
              exchange.id === id
                ? { ...exchange, answer: words.slice(0, index + 1).join(" ") }
                : exchange,
            ),
          );
        }, index * 60),
      );
    });
    // Refill the composer once the reply is done, so the next send is one click.
    timers.current.push(
      setTimeout(() => {
        setStreaming(false);
        setDraft(PROMPT);
      }, words.length * 60),
    );
  };

  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <div className="flex flex-wrap items-center gap-0.5 self-center rounded-full bg-zinc-100 p-0.5 dark:bg-[#131315]">
        {MODES.map((candidate) => (
          <button
            key={candidate}
            type="button"
            onClick={() => {
              // A reply still streaming belongs to the old transcript: stop it.
              timers.current.forEach(clearTimeout);
              timers.current = [];
              setStreaming(false);
              setMode(candidate);
              setExchanges(SEED);
              setDraft(PROMPT);
            }}
            className={[
              "h-7 cursor-pointer rounded-full px-3 font-medium font-mono text-[13px] transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60",
              candidate === mode
                ? `${RAISED} text-zinc-900 dark:text-zinc-100`
                : "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100",
            ].join(" ")}
          >
            {candidate}
          </button>
        ))}
      </div>

      <div className="h-[26rem] w-full overflow-hidden rounded-xl bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]">
        <Thread.Root
          key={mode}
          autoScroll={mode}
          className="relative flex h-full w-full overflow-hidden [--thread-overlay-top-height:1rem]"
        >
          {/* Measured by the primitive: a landed turn sits this far below the top. */}
          <Thread.Overlay
            direction="top"
            className="inset-x-0 top-0 z-1 h-(--thread-overlay-top-height) bg-linear-to-b from-white to-transparent dark:from-zinc-900"
          />
          <Thread.Viewport className="h-full w-full overflow-x-hidden overflow-y-auto outline-none [overflow-anchor:auto]">
            <div className="relative flex min-h-full w-full flex-col pt-(--thread-overlay-top-height) pb-(--thread-overlay-bottom-height)">
              {/* The reserve the primitive publishes lands on the last child: the newest turn. */}
              <Thread.Content className="flex min-h-full w-full flex-col gap-5 px-4 [&>*:last-child]:min-h-(--thread-turn-min-height,0px)">
                {exchanges.map((exchange, index) => (
                  <Message.Turn key={exchange.id} className="flex flex-col gap-5">
                    {/* biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role */}
                    <Message.Root role="user" className="group flex w-full flex-col items-end">
                      <Message.Text className="max-w-[80%] rounded-[20px] bg-white px-3.5 py-1.5 text-sm text-zinc-900 leading-6 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_rgb(0_0_0/0.04)] dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16)]">
                        {exchange.question}
                      </Message.Text>
                    </Message.Root>
                    {exchange.answer && (
                      // biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role
                      <Message.Root
                        role="assistant"
                        isLast={index === exchanges.length - 1}
                        className="group flex w-full flex-col"
                      >
                        <Message.Text className="text-sm text-zinc-700 leading-6 dark:text-zinc-300">
                          {exchange.answer}
                        </Message.Text>
                      </Message.Root>
                    )}
                  </Message.Turn>
                ))}
              </Thread.Content>
            </div>
          </Thread.Viewport>
          <Thread.Composer className="absolute inset-x-0 bottom-0 z-2 w-full">
            <div className="flex w-full flex-col px-4 pb-4">
              <Composer.Root onSubmit={handleSubmit} className="flex w-full flex-col">
                <Composer.Container className="cursor-text rounded-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_-1px_rgb(0_0_0/0.08),0_6px_16px_-6px_rgb(0_0_0/0.1)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.2),0_1px_2px_rgb(0_0_0/0.12),0_6px_16px_-6px_rgb(0_0_0/0.22)]">
                  <Composer.Textarea
                    value={draft}
                    onValueChange={setDraft}
                    className="max-h-32 min-h-10 overflow-y-auto px-2.5 pt-2.5 text-sm text-zinc-900 dark:text-zinc-100 **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-6 [&_[data-composer-editor]:focus]:outline-none"
                  >
                    <Composer.Placeholder
                      placeholder="Ask a follow-up…"
                      className="text-zinc-400 leading-6 dark:text-zinc-500"
                    />
                  </Composer.Textarea>
                  <Composer.Actions className="flex h-11 items-center justify-end px-2.5">
                    <Composer.Submit
                      disabled={streaming}
                      className="flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0169cc] bg-linear-to-b from-[oklch(57.2%_0.166_253.2)] to-[oklch(52.9%_0.173_255)] text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.28),0_0_0_1px_oklch(46.5%_0.146_254.8),0_1px_2px_rgb(1_105_204/0.35)] transition-opacity focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40"
                    >
                      <ArrowUp className="size-[15px]" />
                    </Composer.Submit>
                  </Composer.Actions>
                </Composer.Container>
              </Composer.Root>
            </div>
          </Thread.Composer>
        </Thread.Root>
      </div>

      <p className="text-center text-xs text-zinc-500 dark:text-zinc-400">{CAPTIONS[mode]}</p>
    </div>
  );
};

// The raised surface of the selected mode.
const RAISED =
  "bg-white bg-linear-to-b from-white to-[#fdfdfd] shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]";

Opening position

Where a saved transcript opens is a consequence of the mode — there is no separate defaultScrollPosition prop. "bottom" opens at the end; "follow" and "jump" open with the newest turn's top at the reading line (the reserve does this); "off" opens at the start. To deep-link into the middle of a transcript, call scrollToMessage on mount — it queues until the rows exist and overrides the landing.

The follow is released by deliberate upward reading intent and re-arms when you return to the bottom (see Keyboard). Content growth alone never releases it: a large code block landing at once won't drop the follow mid-stream. Once you scroll up, the follow can't scroll again until you return to the bottom.

Sizing the last turn

A turn lands at the top because it is at least one visible area tall. The thread measures that area — the root's height minus the top overlay and the docked composer — and publishes it as --thread-turn-min-height on Thread.Content. Three pieces of markup make the landing exact:

  • Give the reserve to the whole turn. Apply the variable to the last child, and make that child the turn: wrap each question and its reply in Message.Turn. On a bare reply, the question above it is pushed off the top.
  • Mount a top overlay. Its height is the gap a landed turn keeps from the top edge. Without one, the turn lands flush against it.
  • Pad the content by the same insets. Use --thread-overlay-top-height and --thread-overlay-bottom-height on the wrapper inside the viewport. Any other vertical padding or margin between the viewport and the turns shifts the landing by that much.
tsx
<Thread.Root className="relative h-full [--thread-overlay-top-height:1rem]">
  <Thread.Overlay direction="top" className="inset-x-0 top-0 h-(--thread-overlay-top-height)" />
  <Thread.Viewport className="h-full overflow-y-auto">
    <div className="flex min-h-full flex-col pt-(--thread-overlay-top-height) pb-(--thread-overlay-bottom-height)">
      <Thread.Content className="flex flex-col gap-4 [&>*:last-child]:min-h-(--thread-turn-min-height,0px)">
        {turns.map((turn) => (
          <Message.Turn key={turn.key}>{/* question, then reply */}</Message.Turn>
        ))}
      </Thread.Content>
    </div>
  </Thread.Viewport>
  <Thread.Composer className="absolute inset-x-0 bottom-0">{/* … */}</Thread.Composer>
</Thread.Root>

The composer's height is measured and written to --thread-overlay-bottom-height for you. Without a Thread.Composer, the area leaves 128px at the bottom, so set --thread-overlay-bottom-height: 128px for the padding to match.

Jumping to a message

scrollToMessage finds a row by the data-message-id attribute you put on it. There is no wrapper part and no registry: rows resolve lazily at call time, so a long transcript adds no per-row setup, only the lookup when you jump. useThreadVisibility reads the same attribute to report which rows are on screen, and creates its observers only once something subscribes.

How does scrollToMessage find a row?
By the data-message-id attribute you put on it, looked up at call time.
Do rows have to be registered first?
No. Nothing is registered up front, so there is no wrapper part to render.
What does a long transcript cost?
The same as a short one: rows are resolved lazily, only when you jump.
How does the outline know what I'm reading?
useThreadVisibility reads the same attribute to report which rows are on screen.
And when nothing subscribes to it?
Its observers are created on the first subscriber, so an unused one costs nothing.
Can I align the target differently?
Pass align: start, center or end; this outline lands each question at the top.
Does it work with streaming?
Yes. Jumping releases the follow, the same as scrolling away by hand.
"use client";

import { Message } from "@intentface/chat/message";
import { Thread, useThread, useThreadVisibility } from "@intentface/chat/thread";

const EXCHANGES = [
  {
    question: "How does scrollToMessage find a row?",
    answer: "By the data-message-id attribute you put on it, looked up at call time.",
  },
  {
    question: "Do rows have to be registered first?",
    answer: "No. Nothing is registered up front, so there is no wrapper part to render.",
  },
  {
    question: "What does a long transcript cost?",
    answer: "The same as a short one: rows are resolved lazily, only when you jump.",
  },
  {
    question: "How does the outline know what I'm reading?",
    answer: "useThreadVisibility reads the same attribute to report which rows are on screen.",
  },
  {
    question: "And when nothing subscribes to it?",
    answer: "Its observers are created on the first subscriber, so an unused one costs nothing.",
  },
  {
    question: "Can I align the target differently?",
    answer: "Pass align: start, center or end; this outline lands each question at the top.",
  },
  {
    question: "Does it work with streaming?",
    answer: "Yes. Jumping releases the follow, the same as scrolling away by hand.",
  },
];

// Every message is addressable; the outline jumps to questions.
const TURNS = EXCHANGES.flatMap((exchange, index) => [
  { id: `q-${index + 1}`, role: "user" as const, text: exchange.question },
  { id: `a-${index + 1}`, role: "assistant" as const, text: exchange.answer },
]);

const QUESTIONS = TURNS.filter((turn) => turn.role === "user");

/*
 * Addressing a row without registering it.
 *
 * `scrollToMessage` finds a row by the `data-message-id` attribute you put on
 * it. There is no wrapper part and no registry: rows are resolved lazily at
 * call time, so a long transcript adds no per-row setup, only the lookup.
 *
 * `useThreadVisibility` is the other half. It reads the same attribute to
 * report which rows are on screen, and it creates its observers on the first
 * subscriber — a thread that never calls it pays nothing.
 */
export const Jump = () => (
  <div className="h-80 w-full max-w-xl overflow-hidden rounded-xl bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]">
    <Thread.Root autoScroll="off" className="relative flex h-full w-full overflow-hidden">
      <Thread.Viewport className="h-full min-w-0 flex-1 overflow-y-auto outline-none">
        {/* Right padding keeps the text clear of the ladder. */}
        <Thread.Content className="flex w-full flex-col gap-5 py-4 pr-12 pl-4">
          {TURNS.map((turn) => (
            <Message.Root
              key={turn.id}
              role={turn.role}
              data-message-id={turn.id}
              className="group flex w-full flex-col data-[role=user]:items-end"
            >
              <Message.Text className="text-sm text-zinc-700 leading-6 group-data-[role=user]:max-w-[80%] group-data-[role=user]:rounded-[20px] group-data-[role=user]:bg-white group-data-[role=user]:px-3.5 group-data-[role=user]:py-1.5 group-data-[role=user]:text-zinc-900 group-data-[role=user]:shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_rgb(0_0_0/0.04)] dark:text-zinc-300 dark:group-data-[role=user]:bg-zinc-800 dark:group-data-[role=user]:text-zinc-100 dark:group-data-[role=user]:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16)]">
                {turn.text}
              </Message.Text>
            </Message.Root>
          ))}
        </Thread.Content>
      </Thread.Viewport>

      {/* Inside the Root, which is how it reaches the scroll context; the
          ladder is this demo's, not a part of the package. */}
      <Ladder />
    </Thread.Root>
  </div>
);

/**
 * A Notion-style ladder on the right edge: one tick per question, the one being
 * read in ink. Hovering it (or tabbing into it) opens the outline in its place.
 */
const Ladder = () => {
  const { scrollToMessage } = useThread();
  const { currentMessageId } = useThreadVisibility();

  // A reply belongs to the question before it, so either one marks that turn.
  const currentIndex = TURNS.findIndex((turn) => turn.id === currentMessageId);
  const activeId = currentIndex === -1 ? null : TURNS[currentIndex - (currentIndex % 2)].id;

  return (
    <nav
      aria-label="Transcript outline"
      className="group/ladder absolute top-1/2 right-1.5 z-10 -translate-y-1/2"
    >
      {/* Decorative: the outline below carries the buttons. */}
      <div
        aria-hidden="true"
        className="flex flex-col items-end gap-2.5 p-2 transition-opacity group-focus-within/ladder:opacity-0 group-hover/ladder:opacity-0"
      >
        {QUESTIONS.map((question) => (
          <span
            key={question.id}
            className={[
              "h-0.5 w-4 rounded-full transition-colors",
              question.id === activeId
                ? "bg-zinc-900 dark:bg-zinc-100"
                : "bg-zinc-300 dark:bg-zinc-600",
            ].join(" ")}
          />
        ))}
      </div>
      <div className="pointer-events-none absolute top-1/2 right-0 flex w-60 origin-right -translate-y-1/2 scale-95 flex-col gap-px rounded-xl bg-white p-1 opacity-0 shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_12px_32px_-8px_rgb(0_0_0/0.16)] transition-[opacity,scale] duration-150 group-focus-within/ladder:pointer-events-auto group-focus-within/ladder:scale-100 group-focus-within/ladder:opacity-100 group-hover/ladder:pointer-events-auto group-hover/ladder:scale-100 group-hover/ladder:opacity-100 dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.16),0_12px_32px_-8px_rgb(0_0_0/0.4)]">
        {QUESTIONS.map((question) => (
          <button
            key={question.id}
            type="button"
            aria-current={question.id === activeId ? "true" : undefined}
            onClick={() => scrollToMessage(question.id, { align: "start" })}
            className={[
              "flex h-8 shrink-0 cursor-pointer items-center rounded-lg px-2.5 text-left text-[13px] transition-colors focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:-outline-offset-2",
              question.id === activeId
                ? "bg-zinc-950/5 font-medium text-zinc-900 dark:bg-white/8 dark:text-zinc-100"
                : "text-zinc-500 hover:bg-zinc-950/5 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/8 dark:hover:text-zinc-100",
            ].join(" ")}
          >
            <span className="truncate">{question.text}</span>
          </button>
        ))}
      </div>
    </nav>
  );
};

Why the scroll subsystem is cheap

Thread's scroll subsystem is built to stay out of the way while a reply streams: the work it does is event-driven and small, not per token or per frame.

  • No scroll handler. Edge detection is an IntersectionObserver sentinel per edge, computed off the main thread. Scrolling runs zero JavaScript.
  • Landing and follow are event-driven — a MutationObserver for new turns, a ResizeObserver for growth, one scrollTo per change. No per-token geometry reads, no animation-frame polling.
  • Edge state lives in external stores (one per edge), so a flip re-renders only the components that read it (your scroll button) — never the Thread tree.
  • Lazy capabilities cost nothing until used: visibility tracking creates its observers on the first useThreadVisibility subscriber and tears them down with the last; the prepend-preservation scroll listener exists only when preserveScrollOnPrepend is set.

The boundary: Thread does not virtualize. Cost is O(rendered rows) of DOM, which holds comfortably for realistic transcripts (hundreds to low thousands of turns). What re-renders during a stream is decided by your message components — see Composer performance.

Keyboard

The viewport carries tabIndex=0, so keyboard users can Tab to it and scroll with the usual keys. Scrolling is otherwise native — the thread intercepts only the upward keys, ArrowUp, PageUp and Home, which release auto-follow. Scrolling down never releases it: doing so at the bottom would leave the view unfollowed while pinned there.

An upward wheel or a downward touch-drag releases follow the same way; a scrollbar drag away from the bottom releases it via the sentinel.

API reference

Every part accepts className, style, and render (see Styling) and emits a bespoke part attribute (data-<part>) unless noted. Only part-specific props and state-driven attributes are listed below.

Thread

The root: a positioned, overflow-clipped container that owns the scroll subsystem and measures the composer dock. Renders data-thread-root.

PropTypeDefaultDetails
autoScroll"off" | "bottom" | "jump" | "follow""follow"
preserveScrollOnPrependbooleanfalse
AttributeDescription
data-thread-root
The root element.
data-at-top
Present while the top edge is in view (start of the transcript) — the CSS-only mirror of useThread().isAtTop.
data-at-bottom
Present while the bottom edge is in view (at the live end) — the CSS-only mirror of useThread().isAtBottom.

Thread.Overlay

A positioned fade strip at the top or bottom edge. The top overlay's height is also the top inset the viewport reserves.

PropTypeDefaultDetails
direction"top" | "bottom"(required)
AttributeDescription
data-thread-overlay
Which edge this overlay marks — style the fade direction from it. The top one doubles as the top-inset measurement target."top" | "bottom"

Thread.Viewport

The scroll container plus the measured content column and the 1px edge sentinels (top + bottom). Focusable so keyboard users can scroll it.

AttributeDescription
data-thread-scroller
The scroll container (role=region, tabIndex 0, aria-label "Messages").
data-thread-content
The content column (role=log, aria-relevant="additions") where your messages render.
data-thread-top
The 1px at-top sentinel the IntersectionObserver watches.
data-thread-bottom
The 1px at-bottom sentinel the IntersectionObserver watches.

Thread.Composer

Bottom-docked slot; its height is measured to inset the viewport. Renders data-thread-composer.

Anything inside it that should not push content up — a floating scroll button, an overlay panel — has to be out of the slot's flow (absolute, or portaled like Composer.Panel's default). An in-flow Composer.Panel (anchor={false}) is part of the dock, so the viewport insets around it.

Thread.Placeholder

Empty-state slot, shown when there are no messages. Renders data-thread-placeholder.

Thread.Content

The column inside the viewport that holds the messages. Renders data-thread-content, and carries the auto-scroll reserve as --thread-turn-min-height on its last child.

There is no scroll-to-bottom part — build one from useThread(), which exposes isAtBottom and scrollToBottom:

tsx
const { isAtBottom, scrollToBottom } = useThread();

return isAtBottom ? null : (
  <button type="button" onClick={() => scrollToBottom()} aria-label="Scroll to latest">
    <ArrowDownIcon />
  </button>
);

CSS variables

The thread reads these, so you can override them from your own CSS:

CSS variableDescription
--thread-width
Max width of the content column and overlays.672px
--thread-overlay-top-height
Top overlay height and top inset.4rem
--thread-overlay-bottom-height
Bottom overlay height and bottom inset (measured from the Thread.Composer slot at runtime).8rem

useThread

Read the scroll state and issue commands from anywhere inside <Thread.Root>:

PropTypeDefaultDetails
isAtTopboolean—
isAtBottomboolean—
scrollToBottom(behavior?) => void—
scrollToTop(behavior?) => void—
scrollToMessage(id, options?) => boolean—

scrollToMessage resolves rows lazily by the data-message-id attribute — put it on each row you want addressable; there is no wrapper component and no per-row cost:

tsx
{turns.map((turn) => (
  <Message.Turn key={turn.key} data-message-id={turn.id}>{/* … */}</Message.Turn>
))}

useThreadVisibility

Track which rows are in view — e.g. to highlight the active turn in an outline. Subscribing lazily creates the tracking observers; when the last subscriber unmounts they are torn down, so threads that never call it pay nothing. Rows are identified by the same data-message-id attribute scrollToMessage uses.

PropTypeDefaultDetails
visibleMessageIdsstring[]—
currentMessageIdstring | null—