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. - Get started — see Quick start to add the package.
Anatomy
The bare nesting — Thread provides the scroll context its parts read:
<Thread.Root>
<Thread.Viewport>{/* messages */}</Thread.Viewport>
<Thread.Composer>{/* composer */}</Thread.Composer>
<Thread.ScrollButton />
</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>
<Thread.ScrollButton />
<Thread.Overlay direction="bottom" />
</Thread.Root>Auto-scroll
autoScroll controls how the newest turn lands and whether the view follows a
stream. Thread.Viewport maps --thread-turn-min-height onto its last child,
so the reserve that lets the newest 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. |
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.
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 | — |
Performance
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.
Accessibility
- Viewport is a focusable
role="region"with a defaultaria-label="Messages"(overridable), so it's reachable and scrollable by keyboard. - Content column is a
role="log"witharia-relevant="additions": a turn announces when its row is added. In-place text mutation — tokens streaming into an existing row — deliberately does not re-announce (token-by-token narration would be noise). If you want end-of-response announcements, add a consumer-ownedrole="status"region that flips on completion. - Reduced motion. Programmatic scrolls requested as
"smooth"(scrollToBottom,scrollToTop,scrollToMessage, and the auto-follow) downgrade to instant when the OS hasprefers-reduced-motionset.
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 | Description |
|---|---|
data-thread-root | The root element. |
data-at-top | Present while the top edge is in view (start of the transcript) — the CSS-only mirror of useThread().isAtTop. |
data-at-bottom | Present while the bottom edge is in view (at the live end) — the CSS-only mirror of useThread().isAtBottom. |
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 | Description |
|---|---|---|
data-thread-overlay | "top" | "bottom" | Which edge this overlay marks — style the fade direction from it. The top one doubles as the top-inset measurement target. |
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 | The scroll container (role=region, tabIndex 0, aria-label "Messages"). |
data-thread-content | The content column (role=log, aria-relevant="additions") where your messages render. |
data-thread-top | The 1px at-top sentinel the IntersectionObserver watches. |
data-thread-bottom | The 1px at-bottom sentinel the IntersectionObserver watches. |
Thread.Composer
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 | Description |
|---|---|---|
--thread-width | 672px | Max width of the content column and overlays. |
--thread-overlay-top-height | 4rem | Top overlay height and top inset. |
--thread-overlay-bottom-height | 8rem | Bottom overlay height and bottom inset (measured from the Thread.Composer slot at runtime). |