Message List
An AI transcript whose scroll behaves — glued to the newest token while streaming, released the instant you scroll up, anchored when older pages land, and windowed so a 1000-turn thread stays 12 nodes wide.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/message-list.jsonPrompt
Build a React + TypeScript + Tailwind "MessageList" component with zod and
lucide-react. It is the SCROLL CONTAINER of an AI transcript — the bubble skin
is the easy part, the scroll contract is the product.
Contract
- A zod schema (`messageListItemSchema` in a sibling contract file) is the single
source of truth for one turn: { id, role: "user" | "assistant" | "system",
content, timeLabel?, author?, state?: "streaming" | "settled" | "failed",
error? }. `messageListStatusSchema` — "loading" | "empty" | "error" | "ready" —
is the transcript's own render state, independent of a turn's `state`.
- `timeLabel` is ALREADY FORMATTED. The component never calls new Date() /
Intl.*(undefined), so SSR and hydration emit identical bytes and screenshots
are reproducible. Ordering is the data layer's job: items are rendered oldest
→ newest exactly as given, never sorted.
- Props: items; status; hasOlder?; onLoadOlder?; loadingOlder?; onRetryLoad?;
onRetryMessage?; onFollowChange?(following); emptyState?; height? (default
"26rem"); label?; authors?: Partial<Record<role, string>>; skeletonCount?
(clamped 1–12); virtualizeFrom? (default 60, clamped ≥ 8, Infinity disables);
overscan? (clamped 0–40); estimateItemHeight? (clamped 24–800); className.
- The forwarded ref is an imperative handle, not the DOM node:
{ scrollToLatest({smooth?}), scrollToMessage(id, {align?, focus?}),
isFollowing() }.
- Affordances are handler-gated so nothing dead ever renders: no onRetryMessage
means a failed turn shows its reason with no Retry button; no onRetryLoad
means the error branch has no button; no hasOlder means no pagination strip.
Behavior — the follow → takeover → return machine (this is the whole component)
- FOLLOWING: after every commit, a layout effect assigns
scrollTop = scrollHeight - clientHeight. Before paint, never with
behavior:"smooth" — a smooth pin animates across frames, so the next chunk
lands mid-animation and the transcript visibly rubber-bands.
- TAKEOVER: the criterion is "the position differs from the one I last wrote
myself", NOT "distance to bottom > N" and NOT "a scroll event fired".
Appending content changes scrollHeight, never scrollTop, and therefore emits
no scroll event at all; the only scroll event while following is the one our
own pin causes, and we record that value first. So a reported scrollTop more
than 2px above the pinned value can only be a human: wheel, touch, scrollbar
drag, keyboard, find-in-page, screen-reader caret. Two guards complete it:
the check is skipped when the reader is within 1px of the floor (the browser
clamping scrollTop after content SHRINKS is not a takeover), and the 2px slack
absorbs fractional scrollTop at non-integer zoom.
Do not gate takeover on wheel/touch listeners instead: those miss scrollbar
drags, keyboard scrolling and find-in-page, and they are not needed.
- RETURN: while detached, auto-follow is re-armed only by DOWNWARD motion that
lands within 24px of the floor (or by sitting within 1px of it). Without the
direction test, a 3px nudge upward is still "within 24px of the bottom" and
instantly re-arms the glue, which reads as the list fighting the reader.
- A "jump to latest" pill sits OUTSIDE the log region — it is the affordance FOR
a new message, it must not be announced AS one. It counts arrivals since the
takeover, derived from the id last seen while following rather than a tally
that can drift, and honours prefers-reduced-motion by jumping instantly.
- LOAD OLDER: hasOlder + scrollTop <= 96px fires onLoadOlder exactly once;
scrolling back past 320px re-arms it, and so does a new items array.
- ANCHORING: whenever the reader is detached, one rule runs before paint —
measure how much the content ABOVE the anchor message (the topmost visible
one, identified by id) grew, and add exactly that delta to scrollTop. The
delta is computed from real rects (anchor.top minus content.top), so it is
exact rather than modelled. That one rule covers a prepended page, a late
measurement replacing an estimate, and a window shift. Verify it by deleting
the correction: the message the reader was on must visibly fly away.
- WINDOWING (> virtualizeFrom messages): a prefix-sum layout over per-id
measured heights (a shared ResizeObserver feeds it; the estimate for
never-rendered turns is the running mean of the measured ones) plus two spacer
divs. Crucially the window is expressed as an ANCHOR ID + direction, never as
a scrollTop range: when following it is the tail (so the growing message is
always mounted, whatever the model believes), otherwise it is the slice
starting at the anchor. A page prepended above shifts every index by 20 and
the window does not move. Costs: text selection cannot span unmounted turns,
find-in-page only sees mounted turns, and the scrollbar is an estimate until
the reader has visited a region (~2% off at 1000 messages in practice).
- Streaming: a turn with state "streaming" grows a caret and carries
aria-live="off" so per-token mutation is not re-read; the finished reply is
announced once, in full, by a separate sr-only role="status" region below the
log. A failed turn with no content renders its reason with no empty bubble.
Rendering & styling
- Semantic tokens only: bg-card (shell, pill), bg-muted / text-muted-foreground
(assistant bubble, skeletons, meta), bg-primary / text-primary-foreground
(the reader's own turns), bg-primary/10 (assistant glyph), text-destructive +
ring-destructive/60 (failed), focus-visible:ring-2 ring-ring everywhere.
cn() merges the consumer className into the shell.
- The bubble wraps with `overflow-wrap: anywhere`, NOT `break-words`: only the
former shrinks min-content width, and a 150-character URL under break-words
measured 77px of real horizontal scroll inside a 375px transcript.
- Vertical rhythm lives in each message's padding, never in margins, so a
windowed spacer can reproduce a message's footprint exactly.
- role="log" (not role="feed"): a transcript is append-at-the-end content that
must be ANNOUNCED as it arrives, which is what log's implicit live region
gives you; feed is a browsing structure for independent articles, is not a
live region, and expects the reader — not the app — to drive the scroll,
which is the opposite of auto-follow. aria-live="polite" +
aria-relevant="additions" keep the backlog from being re-read, and the region
is keyed on status so a loading → ready flip re-inserts it instead of
announcing the whole history at once.
- Each turn is an <article> with aria-posinset / aria-setsize and a roving
tabindex: the log is ONE tab stop, ArrowUp/Down/Home/End move between turns
(PageUp/PageDown/Space are left to the browser so screenful scrolling still
works), and navigating to a windowed-out turn mounts it first, then focuses it
with preventScroll plus a hand-computed scrollTop — scrollIntoView() would
drag every ancestor and yank the page around the transcript.
- Only the caret pulse and skeleton shimmer animate, both with
motion-reduce:animate-none; the smooth jump degrades to an instant one.
Customization levers
- Thresholds: TAKEOVER_PX 2 (raise for a trackpad-heavy audience, never above
~8 or small scrolls stop registering), RETURN_PX 24 (the "close enough to the
bottom" zone), TOP_TRIGGER_PX 96 / TOP_RELEASE_PX 320 (how eagerly history
paginates).
- Bubble domain: swap role/state for your own turn taxonomy (tool calls,
reasoning, citations) — keep `id` (measurement + anchor key), `state`
(streaming/failed branches) and the oldest → newest order.
- Density: px-4 py-2.5 bubbles with pt-4 between groups read as "comfortable";
drop to py-1.5 / pt-2 with a size-6 glyph for a dense agent log without
touching the scroll machinery.
- Windowing: raise virtualizeFrom to keep everything mounted (Infinity disables
it entirely and buys back text selection and find-in-page); raise overscan to
trade nodes for fewer blank flashes on a fast flick; estimateItemHeight only
matters before anything has been measured.
- Chrome per surface: `authors` renames the roles, `label` names the log,
`emptyState` replaces the whole zero-state, `height` can be any CSS length
including "100%" inside a flex column.Concepts
- Follow / takeover / return — three states, not a boolean. Following pins the viewport to the floor; takeover is triggered by the reader moving it and freezes everything; return needs downward motion into the last 24px, so a small upward nudge cannot accidentally re-arm the glue the reader just escaped.
- The takeover test — compare the reported scrollTop with the one you last wrote yourself, not with the distance to the bottom. Appending content moves scrollHeight and not scrollTop, so growth cannot fabricate a scroll event; every other source of movement is a human, including scrollbar drags and find-in-page that wheel/touch listeners would miss.
- Pin before paint — the position is assigned in a layout effect, so the reader never sees an intermediate frame. A smooth pin is the opposite: it animates across frames, the next chunk lands mid-animation, and a distance-based detector reads the animation as the reader taking over.
- Anchor compensation — while detached, the only thing the list does on a commit is measure how much the content above the anchor message grew and add that delta to scrollTop. One rule, three features: prepending history, correcting an estimated height, and shifting the window all preserve the reader's pixel row.
- Anchor-keyed windowing — the mounted slice is described by a message id plus a direction rather than by a scrollTop range, which is why inserting twenty older messages shifts every index without moving the window. Heights are cached per id, so a prepend never invalidates a measurement.
- Muted while streaming, announced when done — per-token growth inside a live region would machine-gun a screen reader, so the growing turn carries
aria-live="off"and a separate polite status region speaks the finished reply exactly once.
Streaming Text
An incremental Markdown renderer for LLM output — frozen blocks, speculative closing of half-written markup, an inline cursor and one screen-reader announcement per answer.
Chat Composer
A message composer that owns the send⇄stop run — clears the draft on send, aborts on stop, restores it on failure, and gates send on attachment uploads.