Anatomy of the Conversation Thread
How a chat surface stays pinned to the bottom with one arithmetic check, springs each message in, and reveals tokens on a plain interval.
↓Scroll to step through it
The three pieces
A chat thread has to stay where the reader expects while new messages keep
landing. ConversationThread handles that with a single boolean derived from
scroll math, and hands every message's entrance to one repeated spring. There
is no physics engine: a threshold check, a transform, and a setInterval.
ConversationThread owns the scroll container and the pinned/jump-to-latest
state. ConversationMessage renders one turn: role, bubble tint, alignment,
hover actions. StreamingText is independent of both: a standalone string
that reveals itself over time and reports back through onDone.
role decides two things at once: which bubble tint from
BUBBLE_BY_ROLE, and whether the row reverses to sit on the right. A
ThreadContext provider on the outer ConversationThread carries one more
input, variant, down to every message without prop-drilling it through
children: document turns off the reversal and lets the bubble go full
width, compact drops the name/timestamp row and tightens the gap. Same
component, three readings of the same role.
Staying pinned to the bottom
ConversationThread recomputes "am I at the bottom" on every onScroll and
stores the answer as pinned (mirrored into a ref so observers never read a
stale value). New messages and streaming growth both re-stick while pinned;
if the reader scrolls up past the threshold, it lets go and shows a pill.
// New message mounts → instant stick (smooth is reserved for Jump).
React.useEffect(() => {
if (autoScroll && pinnedRef.current) scrollToBottom("instant");
}, [childCount, autoScroll, scrollToBottom]);
// StreamingText / wrap growth → observe the *inner* stack. A ResizeObserver
// on the overflow port itself does not fire when only scrollHeight grows.
React.useEffect(() => {
const content = contentRef.current;
if (!autoScroll || !content) return;
const ro = new ResizeObserver(() => {
if (pinnedRef.current) scrollToBottom("instant");
});
ro.observe(content);
return () => ro.disconnect();
}, [autoScroll, scrollToBottom]);childCount alone only notices a new message, not every StreamingText
tick that grows a bubble. The ResizeObserver on the inner content wrapper
closes that gap without a MutationObserver or a rAF loop.
A 0px threshold would un-pin the instant a subpixel scroll event fires.
Trackpads and momentum scrolling report fractional deltas constantly, so an
exact-bottom check flickers the pill in and out on scroll noise alone. 48
absorbs that jitter and is still smaller than a single message row, so the
thread lets go only once the reader has deliberately scrolled, never on
rounding error.
Streaming text changes height every few milliseconds. Smooth scrolling
and Framer Motion layout both tween those changes, and they fight each
other: the viewport eases toward the bottom while every sibling message
re-measures and slides. Result: jump up, settle down, jump again.
So the thread keeps stickiness instant while pinned, and messages enter
with opacity + a short y spring only, with no layout prop. scrollToBottom("smooth")
is reserved for the intentional Jump-to-latest click.
When pinned flips to false, AnimatePresence mounts a button on the same
spring used everywhere else in GodUI's AI components. It is sticky bottom-2,
so it rides the current scroll position instead of a fixed page coordinate:
<AnimatePresence>
{!pinned ? (
<motion.button
initial={{ opacity: 0, y: 8, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.9 }}
transition={{ type: "spring", stiffness: 320, damping: 32, mass: 0.9 }}
onClick={() => {
setPinned(true);
scrollToBottom("smooth");
}}
>
Jump to latest
<ArrowDownIcon />
</motion.button>
) : null}
</AnimatePresence>Clicking it does two things: it sets pinned back to true and
calls scrollToBottom directly. Setting pinned alone would only affect the
next content change. The click needs the thread to catch up immediately,
so the imperative scroll and the state flip happen in the same handler.
The showpiece detail: streaming tokens
StreamingText has no dependency on the thread or the message it lives in.
It owns one count of how many characters to slice off text, and a
setInterval that raises it by chunk every speed milliseconds:
Math.min(current + chunk, text.length) is the only bounds check in the
component. Without it the last tick could slice past the string's end
(harmless in JS, but the check also stops the interval from firing one
extra time before clearInterval runs). The blinking caret isn't
StreamingText's concern: ConversationMessage renders it as a
plain animate-pulse span whenever its own streaming prop is true,
independent of how far count has gotten.
{streaming ? (
<span className="ml-0.5 inline-block h-[1.05em] w-[2px] -translate-y-px animate-pulse bg-current align-middle motion-reduce:animate-none" />
) : null}The split lets a consumer drive streaming off their own state
(say, "is the socket still open") independently of whether StreamingText
has finished revealing the current chunk of text it already has.
Every piece here is either event-driven or cleans up after itself.
onScroll only runs while the user is scrolling. The
ResizeObserver only fires when the message stack's box changes, and it
disconnects on unmount. StreamingText's setInterval is cleared both on
completion and on unmount, so no polling loop is left running once a
message has finished streaming or the component has left the tree.
The result
One threshold check keeps the thread pinned, a ResizeObserver sticks through
streaming growth, one spring brings every message in and pops the jump
pill, and one setInterval, guarded by nothing more than Math.min, turns a
finished string into something that reads like it's still being written.
- User bubble
- bg-primary, row reversed, rounded-br-md
- Assistant bubble
- bg-muted, rounded-bl-md
- Actions row
- opacity-0 → 100 on hover, px-1 below the bubble
const BUBBLE_BY_ROLE: Record<MessageRole, string> = {user: "bg-primary text-primary-foreground",assistant: "bg-muted text-foreground",system: "bg-transparent text-muted-foreground italic",};<div className={`flex gap-3 ${isUser && !isDocument ? "flex-row-reverse" : ""}`}>{avatar}<div className={isUser ? "items-end" : "items-start"}> <div className={`rounded-2xl px-3.5 py-2 ${BUBBLE_BY_ROLE[role]} ${isUser ? "rounded-br-md" : "rounded-bl-md"}`}> {children} </div> {actions ? <div className="opacity-0 group-hover/msg:opacity-100">{/* … */}</div> : null}</div></div>Anatomy of the Conversation Thread
How a chat surface stays pinned to the bottom with one arithmetic check, springs each message in, and reveals tokens on a plain interval.
- User bubble
- bg-primary, row reversed, rounded-br-md
- Assistant bubble
- bg-muted, rounded-bl-md
- Actions row
- opacity-0 → 100 on hover, px-1 below the bubble
const BUBBLE_BY_ROLE: Record<MessageRole, string> = {user: "bg-primary text-primary-foreground",assistant: "bg-muted text-foreground",system: "bg-transparent text-muted-foreground italic",};<div className={`flex gap-3 ${isUser && !isDocument ? "flex-row-reverse" : ""}`}>{avatar}<div className={isUser ? "items-end" : "items-start"}> <div className={`rounded-2xl px-3.5 py-2 ${BUBBLE_BY_ROLE[role]} ${isUser ? "rounded-br-md" : "rounded-bl-md"}`}> {children} </div> {actions ? <div className="opacity-0 group-hover/msg:opacity-100">{/* … */}</div> : null}</div></div>The three pieces
A chat thread has to stay where the reader expects while new messages keep
landing. ConversationThread handles that with a single boolean derived from
scroll math, and hands every message's entrance to one repeated spring. There
is no physics engine: a threshold check, a transform, and a setInterval.
ConversationThread owns the scroll container and the pinned/jump-to-latest
state. ConversationMessage renders one turn: role, bubble tint, alignment,
hover actions. StreamingText is independent of both: a standalone string
that reveals itself over time and reports back through onDone.
role decides two things at once: which bubble tint from
BUBBLE_BY_ROLE, and whether the row reverses to sit on the right. A
ThreadContext provider on the outer ConversationThread carries one more
input, variant, down to every message without prop-drilling it through
children: document turns off the reversal and lets the bubble go full
width, compact drops the name/timestamp row and tightens the gap. Same
component, three readings of the same role.
scrollHeight - scrollTop - clientHeight < 48 → setPinned(atBottom)
- Message row
- content taller than the viewport, scrolls under it
- 48px threshold
- scrollHeight − scrollTop − clientHeight
- Jump-to-latest pill
- sticky bottom-2, spring opacity/y/scale
onScroll={(e) => {const el = e.currentTarget;const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48;setPinnedBoth(atBottom);}}Staying pinned to the bottom
ConversationThread recomputes "am I at the bottom" on every onScroll and
stores the answer as pinned (mirrored into a ref so observers never read a
stale value). New messages and streaming growth both re-stick while pinned;
if the reader scrolls up past the threshold, it lets go and shows a pill.
// New message mounts → instant stick (smooth is reserved for Jump).
React.useEffect(() => {
if (autoScroll && pinnedRef.current) scrollToBottom("instant");
}, [childCount, autoScroll, scrollToBottom]);
// StreamingText / wrap growth → observe the *inner* stack. A ResizeObserver
// on the overflow port itself does not fire when only scrollHeight grows.
React.useEffect(() => {
const content = contentRef.current;
if (!autoScroll || !content) return;
const ro = new ResizeObserver(() => {
if (pinnedRef.current) scrollToBottom("instant");
});
ro.observe(content);
return () => ro.disconnect();
}, [autoScroll, scrollToBottom]);childCount alone only notices a new message, not every StreamingText
tick that grows a bubble. The ResizeObserver on the inner content wrapper
closes that gap without a MutationObserver or a rAF loop.
A 0px threshold would un-pin the instant a subpixel scroll event fires.
Trackpads and momentum scrolling report fractional deltas constantly, so an
exact-bottom check flickers the pill in and out on scroll noise alone. 48
absorbs that jitter and is still smaller than a single message row, so the
thread lets go only once the reader has deliberately scrolled, never on
rounding error.
Streaming text changes height every few milliseconds. Smooth scrolling
and Framer Motion layout both tween those changes, and they fight each
other: the viewport eases toward the bottom while every sibling message
re-measures and slides. Result: jump up, settle down, jump again.
So the thread keeps stickiness instant while pinned, and messages enter
with opacity + a short y spring only, with no layout prop. scrollToBottom("smooth")
is reserved for the intentional Jump-to-latest click.
When pinned flips to false, AnimatePresence mounts a button on the same
spring used everywhere else in GodUI's AI components. It is sticky bottom-2,
so it rides the current scroll position instead of a fixed page coordinate:
<AnimatePresence>
{!pinned ? (
<motion.button
initial={{ opacity: 0, y: 8, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.9 }}
transition={{ type: "spring", stiffness: 320, damping: 32, mass: 0.9 }}
onClick={() => {
setPinned(true);
scrollToBottom("smooth");
}}
>
Jump to latest
<ArrowDownIcon />
</motion.button>
) : null}
</AnimatePresence>Clicking it does two things: it sets pinned back to true and
calls scrollToBottom directly. Setting pinned alone would only affect the
next content change. The click needs the thread to catch up immediately,
so the imperative scroll and the state flip happen in the same handler.
count = Math.min(count + chunk, text.length)
- Token chunk
- count += chunk (2 chars) per tick
- Caret
- animate-pulse, only while streaming is true
- Cadence
- setInterval every speed (24ms)
React.useEffect(() => {if (reduce) { setCount(text.length); onDone?.(); return;}setCount(0);let current = 0;const id = setInterval(() => { current = Math.min(current + chunk, text.length); setCount(current); if (current >= text.length) { clearInterval(id); onDone?.(); }}, speed);return () => clearInterval(id);}, [text, chunk, speed, reduce, onDone]);return <>{text.slice(0, count)}</>;The showpiece detail: streaming tokens
StreamingText has no dependency on the thread or the message it lives in.
It owns one count of how many characters to slice off text, and a
setInterval that raises it by chunk every speed milliseconds:
Math.min(current + chunk, text.length) is the only bounds check in the
component. Without it the last tick could slice past the string's end
(harmless in JS, but the check also stops the interval from firing one
extra time before clearInterval runs). The blinking caret isn't
StreamingText's concern: ConversationMessage renders it as a
plain animate-pulse span whenever its own streaming prop is true,
independent of how far count has gotten.
{streaming ? (
<span className="ml-0.5 inline-block h-[1.05em] w-[2px] -translate-y-px animate-pulse bg-current align-middle motion-reduce:animate-none" />
) : null}The split lets a consumer drive streaming off their own state
(say, "is the socket still open") independently of whether StreamingText
has finished revealing the current chunk of text it already has.
Every piece here is either event-driven or cleans up after itself.
onScroll only runs while the user is scrolling. The
ResizeObserver only fires when the message stack's box changes, and it
disconnects on unmount. StreamingText's setInterval is cleared both on
completion and on unmount, so no polling loop is left running once a
message has finished streaming or the component has left the tree.
The result
One threshold check keeps the thread pinned, a ResizeObserver sticks through
streaming growth, one spring brings every message in and pops the jump
pill, and one setInterval, guarded by nothing more than Math.min, turns a
finished string into something that reads like it's still being written.
Accessibility
ConversationMessage reads useReducedMotion() and disables the spring's
initial state (initial={reduce ? false : { opacity: 0, y: 8 }}) so a
reduced-motion reader gets messages at rest instead of mid-flight. There is
no layout animation to gate: position tweening on growing bubbles was
removed because it fought auto-scroll. The caret's animate-pulse carries
motion-reduce:animate-none, and StreamingText skips the interval under
reduced motion: setCount(text.length) and onDone() fire immediately.
initial={reduce ? false : { opacity: 0, y: 8 }}
// no layout — streaming growth must not re-tween siblingsHover actions are real <button>s with aria-label, so they're reachable
and nameable without depending on the hover-only opacity-0 group-hover/msg:opacity-100 styling that reveals them visually.
Motion Score
translatePosition / lift via translateopacityFade / cross-fade