Thread
The scroll container — at-bottom detection, auto-follow, docked-composer measurement, and prepend-aware restoration.
"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/followvia theautoScrollprop (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-idattribute. - 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:
<Thread.Root>
<Thread.Overlay />
<Thread.Viewport>
<Thread.Content />
</Thread.Viewport>
<Thread.Composer />
</Thread.Root>A realistic surface with overlays and a message list:
<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.
| Value | Description |
|---|---|
"follow"default | |
"bottom" | |
"jump" | |
"off" |
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-heightand--thread-overlay-bottom-heighton the wrapper inside the viewport. Any other vertical padding or margin between the viewport and the turns shifts the landing by that much.
<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.
"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
scrollToper 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
useThreadVisibilitysubscriber and tears them down with the last; the prepend-preservation scroll listener exists only whenpreserveScrollOnPrependis 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.
| Prop | Type | Default | Details |
|---|---|---|---|
autoScroll | "off" | "bottom" | "jump" | "follow" | "follow" | |
preserveScrollOnPrepend | boolean | false |
| Attribute | Description |
|---|---|
data-thread-root | |
data-at-top | |
data-at-bottom |
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.
| Prop | Type | Default | Details |
|---|---|---|---|
direction | "top" | "bottom" | (required) |
| Attribute | Description |
|---|---|
data-thread-overlay |
Thread.Viewport
The scroll container plus the measured content column and the 1px edge sentinels (top + bottom). Focusable so keyboard users can scroll it.
| Attribute | Description |
|---|---|
data-thread-scroller | |
data-thread-content | |
data-thread-top | |
data-thread-bottom |
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:
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 variable | Description |
|---|---|
--thread-width | |
--thread-overlay-top-height | |
--thread-overlay-bottom-height |
useThread
Read the scroll state and issue commands from anywhere inside <Thread.Root>:
| Prop | Type | Default | Details |
|---|---|---|---|
isAtTop | boolean | — | |
isAtBottom | boolean | — | |
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:
{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.
| Prop | Type | Default | Details |
|---|---|---|---|
visibleMessageIds | string[] | — | |
currentMessageId | string | null | — |