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.
  • 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.

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.

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

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

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 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.

Accessibility

  • Viewport is a focusable role="region" with a default aria-label="Messages" (overridable), so it's reachable and scrollable by keyboard.
  • Content column is a role="log" with aria-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-owned role="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 has prefers-reduced-motion set.

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
AttributeDescription
data-thread-rootThe root element.
data-at-topPresent while the top edge is in view (start of the transcript) — the CSS-only mirror of useThread().isAtTop.
data-at-bottomPresent 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.

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

AttributeDescription
data-thread-scrollerThe scroll container (role=region, tabIndex 0, aria-label "Messages").
data-thread-contentThe content column (role=log, aria-relevant="additions") where your messages render.
data-thread-topThe 1px at-top sentinel the IntersectionObserver watches.
data-thread-bottomThe 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:

AttributeValuesDescription
--thread-width672pxMax width of the content column and overlays.
--thread-overlay-top-height4remTop overlay height and top inset.
--thread-overlay-bottom-height8remBottom overlay height and bottom inset (measured from the Thread.Composer slot at runtime).