Thread

The scroll container — at-bottom detection, auto-follow, 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/ui/composer";
import { Message } from "@intentface/ui/message";
import { Thread, useThread } from "@intentface/ui/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 floating composer and publishes the room it takes as
// --thread-inset-bottom; Content pads by it, 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-inset-top:1rem] [--thread-composer-gap:2rem]"
      >
        <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">
            <Thread.Content className="mx-auto flex min-h-full w-full max-w-lg flex-col justify-end gap-5 px-4 pt-(--thread-inset-top) pb-(--thread-inset-bottom) [&>*:last-child]:min-h-(--thread-turn-min-height)">
              {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).
  • The layout is yours — the thread measures and publishes CSS variables, and applies none of the layout. You position the composer, size the viewport, pad Thread.Content and give the last turn its min-height, as Spacing a landed turn explains.
  • The viewport needs a definite height — Thread.Viewport is a size container, so its content cannot size it and a viewport sized only by max-height collapses. Give it h-full inside a sized root.
  • Children are turns — in "follow" and "jump" the last child of Thread.Content lands at the top, so a reply on its own row pushes its question out of view. Wrap each question and its reply in one Message.Turn with nothing after the last, or use autoScroll="bottom" for a flat transcript.
  • 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

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

A realistic thread. Every layout class is yours, because the package applies none: the composer's position, the viewport's height, the padding and the last turn's min-height. The fade and the scroll button are optional and not parts:

tsx
<Thread.Root className="relative h-full [--thread-inset-top:1rem] [--thread-composer-gap:2rem]">
  <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-1 h-(--thread-inset-top) bg-linear-to-b from-white to-transparent" />
  <Thread.Viewport className="h-full">
    <Thread.Content className="flex flex-col gap-4 px-4 pt-(--thread-inset-top) pb-(--thread-inset-bottom) [&>*:last-child]:min-h-(--thread-turn-min-height)">
      {turns.map((turn) => (
        <Message.Turn key={turn.key} data-message-id={turn.key}>{/* question, then reply */}</Message.Turn>
      ))}
    </Thread.Content>
  </Thread.Viewport>
  <Thread.Composer className="absolute inset-x-0 bottom-0">
    <Composer.Root onSubmit={sendMessage}>{/* … */}</Composer.Root>
  </Thread.Composer>
  {/* Built from `useThread()`'s `isAtBottom` and `scrollToBottom`. */}
  <ScrollToBottomButton />
</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. "follow" and "jump" land a turn by scrolling to the bottom, so the newest turn reaches the top only when it is tall enough. Spacing a landed turn shows the min-height that gives it that height.

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 with no landing and no follow.
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 { useState } from "react";
import { type Exchange, ModeSwitch, StreamedChat, useStreamedChat } from "./streamed-chat";

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 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.
 *
 * 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 chat = useStreamedChat({ seed: SEED, reply: REPLY });

  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <ModeSwitch
        options={MODES}
        value={mode}
        onChange={(next) => {
          setMode(next);
          chat.reset();
        }}
      />
      <StreamedChat key={mode} chat={chat} autoScroll={mode} />
      <p className="text-center text-xs text-zinc-500 dark:text-zinc-400">{CAPTIONS[mode]}</p>
    </div>
  );
};

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 top inset (the last turn's min-height 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.

Spacing a landed turn

Set --thread-inset-top on Thread.Root for the space above a turn once it lands, and --thread-composer-gap for the room between the last line and the composer. Both default to 0. A turn lands exactly --thread-inset-top from the top only while three values agree:

  • The top padding is --thread-inset-top. Put pt-(--thread-inset-top) on Thread.Content.
  • The bottom padding is --thread-inset-bottom. Thread.Composer writes its distance from the root's bottom edge, plus --thread-composer-gap, to that variable. Put pb-(--thread-inset-bottom) on Thread.Content. Without a Thread.Composer, nothing writes it, so set it on Thread.Root if the transcript needs space at the bottom.
  • The last turn's min-height is --thread-turn-min-height. In "follow" and "jump" the root publishes the viewport's height minus both paddings, so the last turn fills the view between them when scrolled to the bottom. In "bottom" and "off" it publishes 0px, so the rule can stay on Thread.Content in every mode.

Any other padding, or a margin between the viewport and the turns, shifts the landing by its own size. Leave the min-height rule out and "follow" and "jump" land at the bottom like "bottom", which can be a deliberate choice.

The top inset and the gap-* between turns are independent. When the landing gap is larger, the end of the previous turn shows above a landed turn, by the top inset minus the spacing. Cover it with a top fade, or keep the top inset at or below the spacing.

Fading the edges

A fade is your own div inside Thread.Root, absolute, aria-hidden and pointer-events-none. Size the top one with h-(--thread-inset-top) and the bottom one with h-(--thread-inset-bottom), and give a header that floats over the top the same height as the top inset.

tsx
<Thread.Root className="relative h-full [--thread-inset-top:1rem] [--thread-composer-gap:2rem]">
  <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-1 h-(--thread-inset-top) bg-linear-to-b from-white to-transparent" />
  <Thread.Viewport className="h-full">{/* … */}</Thread.Viewport>
  <div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 z-1 h-(--thread-inset-bottom) bg-linear-to-t from-white to-transparent" />
  {/* Above the bottom fade, so the composer stays sharp. */}
  <Thread.Composer className="absolute inset-x-0 bottom-0 z-2">{/* … */}</Thread.Composer>
</Thread.Root>

Laying out without Tailwind

Plain CSS needs the same rules, and the parts' data attributes are the selectors. Give the last turn box-sizing: border-box if it has padding or a border, otherwise its min-height excludes them and the turn lands short of the top.

css
[data-thread-root] {
  height: 100%;
  --thread-inset-top: 1rem;
  --thread-composer-gap: 2rem;
}

[data-thread-scroller] {
  height: 100%;
}

[data-thread-content] {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  padding-inline: 1rem;
  padding-top: var(--thread-inset-top);
  padding-bottom: var(--thread-inset-bottom, 0px);
}

/* 0px outside "follow" and "jump", so the rule can stay in every mode. */
[data-thread-content] > :last-child {
  box-sizing: border-box;
  min-height: var(--thread-turn-min-height);
}

[data-thread-composer] {
  position: absolute;
  inset-inline: 0;
  bottom: 0;
}

/* Your own fade over the top inset. Optional. */
[data-slot="thread-fade"] {
  position: absolute;
  inset-inline: 0;
  top: 0;
  z-index: 1;
  height: var(--thread-inset-top);
  background: linear-gradient(to bottom, white, transparent);
  pointer-events: none;
}
tsx
<Thread.Root>
  <div aria-hidden data-slot="thread-fade" />
  <Thread.Viewport>
    <Thread.Content>
      {turns.map((turn) => (
        <Message.Turn key={turn.key}>{/* question, then reply */}</Message.Turn>
      ))}
    </Thread.Content>
  </Thread.Viewport>
  <Thread.Composer>{/* your composer */}</Thread.Composer>
</Thread.Root>

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.

Only the thread's own viewport scrolls, so a thread embedded in a scrolling page never moves the page. Where the row lands follows the same CSS as the browser's own scroll-into-view: scroll-padding on Thread.Viewport keeps room at its edges (lengths or percentages of its height), and scroll-margin on a row adds room around that row. To land rows clear of the top fade and the composer, pad the viewport by the insets:

tsx
<Thread.Viewport className="scroll-pt-(--thread-inset-top) scroll-pb-(--thread-inset-bottom)">
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/ui/message";
import { Thread, useThread, useThreadVisibility } from "@intentface/ui/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">
      {/* scroll-padding: a question jumped to lands 24px below the top, not flush against it. */}
      <Thread.Viewport className="h-full min-w-0 flex-1 overflow-y-auto scroll-pt-6 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0169cc]/60">
        {/* 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>
  );
};

Timing the scroll

Smooth scrolls are the browser's own until you give them a timing. Set one on the root and the thread animates them itself — the landing, scrollToBottom, scrollToTop and scrollToMessage — and pins the follow:

css
[data-thread-root] {
  --thread-scroll: 450ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

The value reads like transition: a duration, then any CSS easing, linear() included. That makes a Motion spring a one-liner, since spring() prints exactly that pair:

tsx
import { spring } from "motion";

<Thread.Root style={{ "--thread-scroll": String(spring(0.5, 0)) }}>{/* … */}</Thread.Root>

Keep bounce at 0: the scroller can't move past its end, so an overshoot at the bottom is clipped into a hard stop.

autoScroll decides which scrolls happen; the timing only shapes the ones that animate. The follow never does: a reply grows a line at a time, so pinning reads as smooth already, while an animated follow would keep a frame loop on the main thread for the whole stream and trail the newest text.

The timing is one set of values for every scroll, so set it once and leave it. Swapping it from a selector does nothing, because the thread reads it before any attribute changes, and it would cost a restyle of every message, because custom properties inherit.

To pace by distance instead, set --thread-scroll-speed in pixels per second; a duration set alongside it becomes the cap. --thread-scroll: none opts a thread back into the native smooth scroll, over any longhand — useful when a shared stylesheet sets a timing for every thread.

While an animation runs, the root carries data-scrolling, naming why it started: land, bottom, top or message. Use it for styling, such as hiding the scroll button mid-flight.

The destination is re-read every frame, so a reply that starts streaming before the landing finishes is carried along by it instead of restarting it. In "jump" the landing's destination is fixed when it starts: a reply outgrowing the reserve mid-flight never turns the landing into a follow. Any wheel, touch, scroll key or scrollbar press stops the animation where it is, and under prefers-reduced-motion every scroll is instant.

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.

Motion's spring(), as a linear() easing.

"use client";

import { spring } from "motion";
import { type CSSProperties, useState } from "react";
import { type Exchange, ModeSwitch, StreamedChat, useStreamedChat } from "./streamed-chat";

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.",
  },
];

// Each preset is plain CSS on the root. Static timings are Tailwind classes;
// the spring is computed by Motion, so it goes through `style`.
const PRESETS = {
  native: { className: "", style: {}, caption: "No timing set: the browser's own smooth scroll." },
  snappy: {
    className: "[--thread-scroll:250ms_cubic-bezier(0.2,0.8,0.2,1)]",
    style: {},
    caption: "A short ease-out for every scroll.",
  },
  spring: {
    className: "",
    // bounce 0: an overshoot past the bottom is clamped by the scroller.
    style: { "--thread-scroll": String(spring(0.6, 0)) },
    caption: "Motion's spring(), as a linear() easing.",
  },
  paced: {
    className: "[--thread-scroll-speed:1200] [--thread-scroll-duration:700ms]",
    style: {},
    caption: "1200px/s, capped at 700ms: short scrolls are quick.",
  },
  slow: {
    className: "[--thread-scroll:5s_ease-in-out]",
    style: {},
    caption: "A 5s land: the reply streams in while it is still landing.",
  },
} satisfies Record<string, { className: string; style: object; caption: string }>;

type Preset = keyof typeof PRESETS;

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. Measure first: the profiler shows which renders are worth skipping, and which ones were never the problem. Then memoise the expensive subtree, keep its props stable, and leave the cheap rows alone.";

/*
 * Scroll timing, set from CSS.
 *
 * With no timing, smooth scrolls are the browser's own. Set `--thread-scroll`
 * (or its longhands, or a speed) on the root and the thread animates them
 * itself, and pins the follow. Every preset times the landing; the slow one
 * shows a reply streaming in while the landing is still under way. Scroll by
 * hand at any point and the animation stops.
 */
export const ScrollTiming = () => {
  const [preset, setPreset] = useState<Preset>("spring");
  // The reply starts a beat after the send, once the landing is under way.
  const chat = useStreamedChat({ seed: SEED, reply: REPLY, firstWordAfter: 300, wordInterval: 50 });
  const { className, style, caption } = PRESETS[preset];

  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <ModeSwitch
        options={Object.keys(PRESETS) as Preset[]}
        value={preset}
        onChange={(next) => {
          setPreset(next);
          chat.reset();
        }}
      />
      <StreamedChat
        key={preset}
        chat={chat}
        autoScroll="follow"
        style={style as CSSProperties}
        className={className}
      />
      <p className="text-center text-xs text-zinc-500 dark:text-zinc-400">{caption}</p>
    </div>
  );
};

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.
  • A timed scroll costs one frame loop, only while it runs. With --thread-scroll set, each smooth scroll reads its timing once and then does one scrollTo per frame until it ends. Unlike the native smooth scroll, it runs on the main thread, so a frame lost to heavy rendering is a frame the scroll skips. The follow never starts one, because a timed thread pins it. Leave the timing unset to keep the native scroll.
  • 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 Why the composer needs isolating.

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

The root: a positioned, overflow-clipped container that owns the scroll subsystem. It sets position: relative and overflow: hidden inline, publishes --thread-turn-min-height, and carries the composer variables Thread.Composer writes. 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.
data-scrolling
Present while a timed scroll animates, naming why it started. Never set for native or instant scrolls, so never for the follow, which a timed thread pins."land" | "bottom" | "top" | "message"
CSS variableDescription
--thread-inset-top
Set by you. The space above a turn once it lands at the top. Unset means 0, so the turn lands flush.length
--thread-composer-gap
Set by you. The room between the last line and the composer, folded into --thread-inset-bottom. Unset means 0.length
--thread-inset-bottom
Written by Thread.Composer. The distance from the root's bottom edge up to the composer's top, plus --thread-composer-gap. Pad Thread.Content's bottom by it. Removed when the composer unmounts, and yours to set when there is none.
--thread-composer-height
Written by Thread.Composer. The slot's border-box height, rounded to whole pixels. Removed when it unmounts.px
--thread-composer-width
Written by Thread.Composer. The slot's border-box width, rounded to whole pixels. Removed when it unmounts.px
--thread-turn-min-height
Written by the root. In "follow" and "jump", the viewport's height (100cqh) minus --thread-inset-top, --thread-inset-bottom and 1px for the thread's bottom marker. In "bottom" and "off", 0px. Give it to the last turn as min-height.
--thread-scroll
Set by you. Times every smooth scroll but the follow, which it pins; the two parts go in either order, like transition. Unset or none keeps the browser's native smooth scroll (none wins over the longhands); 0ms makes them instant. Takes a Motion spring() string as-is.<duration> <easing> | none
--thread-scroll-duration
Set by you. Longhand; overrides the shorthand. The cap when a speed is set.<duration>
--thread-scroll-easing
Set by you. Longhand; overrides the shorthand. Defaults to ease.<easing-function>
--thread-scroll-speed
Set by you. Pixels per second; the duration follows the distance.<number>

Thread.Viewport

The scroll container. It sets overflow-y: auto, overflow-x: hidden and container-type: size inline, so 100cqh inside it is its height. A size container ignores its content's height, so give it a definite height, such as h-full in a sized root. Focusable so keyboard users can scroll it.

AttributeDescription
data-thread-scroller
The scroll container (role=region, tabIndex 0, aria-label "Messages").

Thread.Content

The column inside the viewport that holds the turns. It has no layout styles of its own. It renders a 1px sentinel above and below itself, as siblings, which the scroll state watches.

AttributeDescription
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

A slot for the composer, with no positioning of its own. A ResizeObserver measures its border box, including when it mounts, remounts or unmounts, and writes --thread-inset-bottom, --thread-composer-height and --thread-composer-width on Thread.Root. It removes all three when it unmounts. Renders data-thread-composer.

Position it yourself, such as with absolute inset-x-0 bottom-0, so the content scrolls under it. Anything inside it that should not add to the inset, such as a floating scroll button or an overlay panel, has to be absolute or portaled like Composer.Panel's default. An in-flow Composer.Panel (anchor={false}) is part of the slot, so --thread-inset-bottom grows to include it.

Thread.Placeholder

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

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—

There is no scroll-to-bottom part. Build one from isAtBottom and scrollToBottom:

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

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

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.key}>{/* … */}</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—