MynduiMyndui
0

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.

01/04The three pieces
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
tsx
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>
01

The three pieces

A chat thread lives or dies on one question: does it stay where the reader expects while new messages keep landing? ConversationThread answers that with a single boolean derived from scroll math, and hands every message's entrance to one repeated spring. Nothing here is a physics engine — it's 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 unrelated to either: a standalone string that reveals itself over time and reports back through onDone.

role decides two independent 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.

02/04Staying pinned to the bottom

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
tsx
onScroll={(e) => {const el = e.currentTarget;const atBottom =  el.scrollHeight - el.scrollTop - el.clientHeight < 48;setPinnedBoth(atBottom);}}
02

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.

tsx
 
// 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 is generous enough to absorb that jitter while still being smaller than a single message row, so the thread only actually lets go 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 — no layout prop. scrollToBottom("smooth") is reserved for the intentional Jump-to-latest click.

When pinned flips to false, AnimatePresence mounts a button — sticky bottom-2, so it rides the current scroll position instead of a fixed page coordinate — off the same spring used everywhere else in Myndui's AI components:

tsx
 
<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, not one: 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.

03/04The showpiece detail: streaming tokens

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)
tsx
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)}</>;
03

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 whole component — without it the last tick could slice past the string's end (harmless in JS, but it's also what stops the interval from firing one extra time before clearInterval runs). The blinking caret isn't StreamingText's concern at all: ConversationMessage renders it as a plain animate-pulse span whenever its own streaming prop is true, independent of how far count has gotten.

tsx
 
{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}

That split matters: a consumer can 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 actually 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 — there's no polling loop left running once a message has finished streaming or the component has left the tree.

04/04Result
You
How do I center a div in 2026?
Myndui
04

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 — with nothing smarter than Math.min guarding it — 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.

tsx
 
initial={reduce ? false : { opacity: 0, y: 8 }}
// no layout — streaming growth must not re-tween siblings

Hover 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

Conversation ThreadSSCompositor-only
StranslatePosition / lift via translate
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →