Thread
The scroll container — at-bottom detection, auto-follow, docked-composer measurement, and prepend-aware restoration.
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. - Costs nothing while streaming — see Why the scroll subsystem is free. There is nothing here to optimise around.
- 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. Thread.Viewport maps
--thread-turn-min-height onto its last child, so the reserve that lets a turn
land at the top is wired for you.
| Value | Description |
|---|---|
"follow"default | Newest lands at the top; the view follows the stream (ChatGPT-style). |
"bottom" | Newest lands at the bottom; the view follows the stream (Codex-style). |
"jump" | Newest lands at the top; the view does not follow. |
"off" | A plain scroll area — no landing, no follow, no reserve. |
Newest lands at the top and the view follows the stream.
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.
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
transcript of ten thousand turns costs the same as this one.
useThreadVisibility reads the same attribute to report which rows are on
screen, and creates its observers only once something subscribes.
Why the scroll subsystem is free
Thread's scroll subsystem is built to cost nothing while a reply streams — you don't need to optimize around it:
- 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 stay free 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 |
|---|---|---|
autoScroll | "off" | "bottom" | "jump" | "follow" | "follow" |
preserveScrollOnPrepend | boolean | false |
| Attribute | Values | Details |
|---|---|---|
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 |
|---|---|---|
direction | "top" | "bottom" | (required) |
| Attribute | Values | Details |
|---|---|---|
data-thread-overlay | "top" | "bottom" |
Thread.Viewport
The scroll container plus the measured content column and the 1px edge sentinels (top + bottom). Focusable so keyboard users can scroll it.
| Attribute | Values | Details |
|---|---|---|
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:
| Attribute | Values | Details |
|---|---|---|
--thread-width | 672px | |
--thread-overlay-top-height | 4rem | |
--thread-overlay-bottom-height | 8rem |
useThread
Read the scroll state and issue commands from anywhere inside <Thread.Root>:
| Prop | Type | Default |
|---|---|---|
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 |
|---|---|---|
visibleMessageIds | string[] | — |
currentMessageId | string | null | — |