--- title: Thread description: The scrolling message viewport with auto-follow, a docked composer, overlays, and a scroll-to-bottom button. source: thread.tsx --- ## 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` / `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 [Installation](/docs/installation) to add the package and copy the component. ## Anatomy The bare nesting — `Thread` provides the scroll context its parts read: ```tsx {/* messages */} {/* composer */} ``` A realistic surface with overlays and a message list: ```tsx {turns.map((turn) => ( {/* … */} ))} {/* … */} ``` ## 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. export const autoScrollModes = [ { value: '"follow"', default: true, description: "Newest lands at the top; the view follows the stream (ChatGPT-style)." }, { value: '"bottom"', description: "Newest lands at the bottom; the view follows the stream (Codex-style)." }, { value: '"jump"', description: "Newest lands at the top; the view does not follow." }, { value: '"off"', description: "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](#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 ``: export const useThreadMembers = [ { name: "isAtTop", type: "boolean", description: "Whether the top sentinel is in view (start of the transcript) — pairs with load-older-history. Subscribes the caller; independent of isAtBottom." }, { name: "isAtBottom", type: "boolean", description: "Whether the bottom sentinel is in view. Subscribes the caller — only components that read it re-render on flips." }, { name: "scrollToBottom", type: "(behavior?) => void", description: "Scroll to the live end." }, { name: "scrollToTop", type: "(behavior?) => void", description: "Scroll to the start; releases the follow." }, { name: "scrollToMessage", type: "(id, options?) => boolean", description: "Scroll to a row carrying data-message-id={id}. Options: align (\"start\" | \"center\" | \"end\" | \"nearest\"), behavior. Called before the transcript loads (a deep link), the jump is queued and runs when the row mounts. Returns false only when the id is absent from a loaded transcript." }, ]; `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: ```tsx {turns.map((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. export const visibilityMembers = [ { name: "visibleMessageIds", type: "string[]", description: "Rows intersecting the viewport, in document order." }, { name: "currentMessageId", type: "string | null", description: "The topmost visible row — the one being read." }, ]; ## 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 [Streaming performance](/docs/headless/performance). ## Keyboard The viewport is focusable, so keyboard users can Tab to it and scroll. Upward gestures also release auto-follow; downward ones never do (releasing at the bottom would strand the view unfollowed while pinned there). export const keyboard = [ { attribute: "Tab", description: "Focus the viewport (a focusable region)." }, { attribute: "ArrowUp / PageUp / Home", description: "Scroll up — and release auto-follow." }, { attribute: "ArrowDown / PageDown / End / Space", description: "Scroll down. Does not release follow." }, ]; An upward wheel or a downward touch-drag releases the 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 [PrimitiveProps](/docs/headless/types)) and emits a bespoke part attribute (`data-`) 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`. export const rootProps = [ { name: "autoScroll", type: '"off" | "bottom" | "jump" | "follow"', default: '"follow"', description: "Landing + follow behavior (see Auto-scroll)." }, { name: "preserveScrollOnPrepend", type: "boolean", default: "false", description: "Hold the reading position when older rows load in above (history pagination). Opt-in — it attaches a passive scroll listener." }, { name: "dockSelector", type: "string", default: 'composer dock slots', description: "CSS selector for the bottom-docked parts the thread reserves space for. Every match is observed for resize; the inset is measured from the bottom-most match." }, ]; export const rootAttrs = [ { attribute: "data-thread-root", description: "The root element." }, { attribute: "data-at-top", description: "Present while the top edge is in view (start of the transcript) — the CSS-only mirror of useThread().isAtTop." }, { attribute: "data-at-bottom", description: "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. export const overlayProps = [ { name: "direction", type: '"top" | "bottom"', default: "(required)", description: "Which edge the overlay marks." }, ]; export const overlayAttrs = [ { attribute: "data-thread-overlay", values: '"top" | "bottom"', description: "The overlay element (the top one is also the top-inset measurement target)." }, { attribute: "data-thread-overlay", values: '"top" | "bottom"', description: "Which edge, for styling the fade direction." }, ]; ### Thread.Viewport The scroll container plus the measured content column and the 1px edge sentinels (top + bottom). Focusable so keyboard users can scroll it. export const viewportAttrs = [ { attribute: "data-thread-scroller", description: "The scroll container (role=region, tabIndex 0, aria-label \"Messages\")." }, { attribute: "data-thread-content", description: "The content column (role=log, aria-relevant=\"additions\") where your messages render." }, { attribute: "data-thread-top", description: "The 1px at-top sentinel the IntersectionObserver watches." }, { attribute: "data-thread-bottom", description: "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`. ### Thread.Placeholder Empty-state slot, shown when there are no messages. Renders `data-thread-placeholder`. ### Thread.ScrollButton Scroll-to-bottom affordance, shown only when not at the bottom (reads `useThread().isAtBottom`). Styled-layer part composed over the hook. ### CSS variables The styled layer exposes layout knobs you can override: export const cssVars = [ { attribute: "--thread-width", values: "672px", description: "Max width of the content column and overlays." }, { attribute: "--thread-overlay-top-height", values: "4rem", description: "Top overlay height and top inset." }, { attribute: "--thread-overlay-bottom-height", values: "8rem", description: "Bottom overlay height and bottom inset (also measured from the composer dock at runtime)." }, ];