Thread

The scroll container — at-bottom detection, auto-follow, docked-composer measurement, and prepend-aware restoration.

What's the difference between useMemo and useCallback?
useMemo caches a computed value; useCallback caches a function reference. In fact useCallback(fn, deps) is just useMemo(() => fn, deps).
So when do I actually need useCallback?
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.

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 modesoff / bottom / jump / follow via the autoScroll prop (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-id attribute.
  • 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.

ValueDescription
"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.
Why does my list re-render on every keystroke?
Because the parent holding the input state re-renders, and every child re-renders with it unless something stops the cascade.
Can I just memo the list?
You can, but only if its props keep reference identity. A fresh array or an inline callback defeats it silently.

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.

Turn 1Question 1: how does this behave when the transcript is long?
Turn 2It resolves the row lazily by its attribute, so nothing is registered up front and a long transcript costs no more than a short one.
Turn 3Question 2: how does this behave when the transcript is long?
Turn 4It resolves the row lazily by its attribute, so nothing is registered up front and a long transcript costs no more than a short one.
Turn 5Question 3: how does this behave when the transcript is long?
Turn 6It resolves the row lazily by its attribute, so nothing is registered up front and a long transcript costs no more than a short one.
Turn 7Question 4: how does this behave when the transcript is long?
Turn 8It resolves the row lazily by its attribute, so nothing is registered up front and a long transcript costs no more than a short one.
Turn 9Question 5: how does this behave when the transcript is long?
Turn 10It resolves the row lazily by its attribute, so nothing is registered up front and a long transcript costs no more than a short one.
Turn 11Question 6: how does this behave when the transcript is long?
Turn 12It resolves the row lazily by its attribute, so nothing is registered up front and a long transcript costs no more than a short one.
Turn 13Question 7: how does this behave when the transcript is long?
Turn 14It resolves the row lazily by its attribute, so nothing is registered up front and a long transcript costs no more than a short one.

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 scrollTo per 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 useThreadVisibility subscriber and tears them down with the last; the prepend-preservation scroll listener exists only when preserveScrollOnPrepend is 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.

PropTypeDefault
autoScroll"off" | "bottom" | "jump" | "follow"
"follow"
preserveScrollOnPrependboolean
false
AttributeValuesDetails
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.

PropTypeDefault
direction"top" | "bottom"
(required)
AttributeValuesDetails
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.

AttributeValuesDetails
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:

AttributeValuesDetails
--thread-width672px
--thread-overlay-top-height4rem
--thread-overlay-bottom-height8rem

useThread

Read the scroll state and issue commands from anywhere inside <Thread.Root>:

PropTypeDefault
isAtTopboolean
isAtBottomboolean
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.

PropTypeDefault
visibleMessageIdsstring[]
currentMessageIdstring | null