Viewport Auto-Scroll#
useThreadViewportAutoScroll is the central hook powering scroll management inside ThreadPrimitive.Viewport. It attaches to the scrollable container DOM element via a composed ref and coordinates three concerns: when to scroll to bottom, how to detect user intent, and how to coexist with the top-anchor reserve system.
Entry point: useThreadViewportAutoScroll.ts
Scroll Triggers#
The hook exposes four independent triggers, each defaulting to true :
| Option | Event | Default behavior |
|---|---|---|
autoScroll | Content resize while at bottom | true (disabled when turnAnchor="top") |
scrollToBottomOnRunStart | thread.runStart AUI event | Schedules "auto" scroll via requestAnimationFrame |
scrollToBottomOnInitialize | First message appears in thread | Schedules "instant" scroll |
scrollToBottomOnThreadSwitch | threadListItem.switchedTo AUI event | Schedules "instant" scroll |
autoScroll determines whether content-resize events continue scrolling the viewport to bottom while the user is already at bottom (i.e., the streaming "follow" behavior). It defaults to false when turnAnchor="top" .
Pending Intent (scrollingToBottomBehaviorRef)#
A single ref, scrollingToBottomBehaviorRef, carries a pending scroll-to-bottom intent as a ScrollBehavior | null . It is planted by any of the triggers above and cleared by one of three conditions :
- Reached bottom with overflow —
handleScrollconfirmsscrollHeight > clientHeight + 1and the difference is ≤ 1px (at-bottom). Clears intent. The overflow guard prevents false-positive clearance when content hasn't grown yet. - User scrolls up with stable content —
handleScrolldetects upward scroll (lastScrollTop > div.scrollTop) and unchangedscrollHeight. Clears intent so future content growth won't re-snap the viewport. This was fixed in PR #4141 after content-driven mutations (markdown re-renders, lazy images) were re-triggeringscrollToBottomeven after users scrolled away. - User pointer-down — A
pointerdownlistener immediately nulls the intent . Prevents an intent kept alive by a non-overflowing thread from hijacking the next content growth (e.g., expanding a tool call result).
While intent is set and content resizes, useOnResizeContent re-invokes scrollToBottom(behavior) on each layout change, continuously following new content during streaming.
In-flight smooth scroll guard: handleScroll suppresses intermediate events during a smooth animation — it no-ops if scrollTop is increasing but hasn't reached bottom yet, preventing isAtBottom flicker .
At-Bottom Detection#
isAtBottom is stored in ThreadViewportStore and updated by handleScroll. The check uses a ≤ 1px threshold :
Math.abs(scrollHeight - scrollTop - clientHeight) <= 1 || scrollHeight <= clientHeight
The <= 1 (not < 1) threshold was introduced in PR #4141 to absorb sub-pixel clipping on high-DPR (Retina) displays, where Chrome macOS clips scrollTop 1px short of its target.
Top-Anchor Conflict Resolution#
When turnAnchor="top" is active, the viewport uses a top-anchor reserve to keep new user messages visible at the top of the scroll area. This system (introduced in PR #3908) lives under packages/react/src/primitives/thread/topAnchor/ and manages a stable DOM reserve element owned by the viewport.
The auto-scroll hook detects an active top-anchor via hasActiveTopAnchor() , which checks turnAnchor === "top", the viewport element identity, and that an anchor element is registered. When a resize event fires with a pending bottom-scroll intent and a top-anchor is active, the intent is surrendered :
if (scrollBehavior && hasActiveTopAnchor()) {
// Let the top-anchor reserve own scrolling — avoid a bottom-scroll race
scrollingToBottomBehaviorRef.current = null;
}
Additionally, thread.runStart skips planting intent entirely when turnAnchor="top" .
The mountTopAnchorReserve.ts module handles the reserve element's scroll correction using behavior: "instant" — changed in PR #4110 from "smooth" to eliminate the one-frame jitter that appeared before the correction landed.
Key Files#
| File | Purpose |
|---|---|
useThreadViewportAutoScroll.ts | Main hook — all scroll intent, trigger, and detection logic |
ThreadViewport.tsx | Consumer — passes options from ThreadPrimitive.Viewport props |
topAnchor/mountTopAnchorReserve.ts | Reserve element mount & instant-scroll correction |
topAnchor/computeTopAnchorSlack.ts | Geometry helpers for anchor target and reserve height |
utils/hooks/useOnResizeContent.ts | Fires callback on any subtree size change — drives streaming follow |
utils/hooks/useOnScrollToBottom.ts | Imperative scroll-to-bottom event subscription |