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 { 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 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.
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 overlays, pad the viewport by
their heights:
<Thread.Viewport className="scroll-pt-(--thread-overlay-top-height) scroll-pb-(--thread-overlay-bottom-height)">"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">
{/* 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 every smooth scroll itself — the landing, the
follow, scrollToBottom, scrollToTop and scrollToMessage:
[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:
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.
While an animation runs, the root carries data-scrolling, naming why it
started: land, follow, bottom, top or message. It is set before the
timing is read, so a selector can time each kind on its own — a slow landing
and a brisk follow:
[data-thread-root] { --thread-scroll: 200ms ease-out; }
[data-thread-root][data-scrolling="land"] { --thread-scroll-duration: 600ms; }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.
Pin the follow instead of animating it. A reply grows a line at a time, so pinning reads as smooth already. Animating the follow keeps a frame loop running for the whole stream — on the main thread, while it is busiest — and the easing trails the newest text below the fold:
[data-thread-root][data-scrolling="follow"] { --thread-scroll-duration: 0ms; }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.
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:200ms_ease-out] data-[scrolling=land]:[--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. While it does, the root carries `data-scrolling` naming why — so a
* selector can time the landing differently from the follow, as the slow
* preset does. 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
scrollToper change. No per-token geometry reads, no animation-frame polling. - A timed scroll costs one frame loop, only while it runs. With
--thread-scrollset, each smooth scroll reads its timing once and then does onescrollToper 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. 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
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 | |
data-scrolling |
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 | |
--thread-scroll | |
--thread-scroll-duration | |
--thread-scroll-easing | |
--thread-scroll-speed |
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 | — |