Thread
The scroll container — at-bottom detection, auto-follow, composer measurement, and prepend-aware restoration.
"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/followvia theautoScrollprop (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.Contentand give the last turn itsmin-height, as Spacing a landed turn explains. - The viewport needs a definite height —
Thread.Viewportis a size container, so its content cannot size it and a viewport sized only bymax-heightcollapses. Give ith-fullinside a sized root. - Children are turns — in
"follow"and"jump"the last child ofThread.Contentlands at the top, so a reply on its own row pushes its question out of view. Wrap each question and its reply in oneMessage.Turnwith nothing after the last, or useautoScroll="bottom"for a flat transcript. - 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
<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:
<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.
| 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 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. Putpt-(--thread-inset-top)onThread.Content. - The bottom padding is
--thread-inset-bottom.Thread.Composerwrites its distance from the root's bottom edge, plus--thread-composer-gap, to that variable. Putpb-(--thread-inset-bottom)onThread.Content. Without aThread.Composer, nothing writes it, so set it onThread.Rootif the transcript needs space at the bottom. - The last turn's
min-heightis--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 publishes0px, so the rule can stay onThread.Contentin 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.
<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.
[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;
}<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:
<Thread.Viewport className="scroll-pt-(--thread-inset-top) scroll-pb-(--thread-inset-bottom)">"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:
[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.
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.
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
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. 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
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 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.
| 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 |
| CSS variable | Description |
|---|---|
--thread-inset-top | |
--thread-composer-gap | |
--thread-inset-bottom | |
--thread-composer-height | |
--thread-composer-width | |
--thread-turn-min-height | |
--thread-scroll | |
--thread-scroll-duration | |
--thread-scroll-easing | |
--thread-scroll-speed |
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.
| Attribute | Description |
|---|---|
data-thread-scroller |
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.
| Attribute | Description |
|---|---|
data-thread-content | |
data-thread-top | |
data-thread-bottom |
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>:
| Prop | Type | Default | Details |
|---|---|---|---|
isAtTop | boolean | — | |
isAtBottom | boolean | — | |
scrollToBottom | (behavior?) => void | — | |
scrollToTop | (behavior?) => void | — | |
scrollToMessage | (id, options?) => boolean | — |
There is no scroll-to-bottom part. Build one from isAtBottom and
scrollToBottom:
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:
{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.
| Prop | Type | Default | Details |
|---|---|---|---|
visibleMessageIds | string[] | — | |
currentMessageId | string | null | — |