ChatThread

A scrolling conversation region that announces new turns to assistive technology.

import { ChatThread } from '@elirobinson/react/components/ai/ChatThread';

Styles: @elirobinson/react/styles/ai/ChatThread.css — already included when you import @elirobinson/react/styles.css.

Deprecated. Use Conversation from @elirobinson/ai-elements. It is backed by use-stick-to-bottom, which already follows the newest turn only for a reader who is already at the bottom, and this system pins its scroll to instant rather than smooth. The announce prop has no direct equivalent — write the attributes instead: aria-live="polite" and aria-relevant="additions text", or aria-live="off" for a replayed thread. This component still works and is removed on the next major.

Jamie9:41 AM
My invoice still shows last month's rate. Did the new one take effect?
Assistant9:41 AM
It did, on the 1st. The invoice you're looking at covers the period before that, so it bills at the old rate. The next one picks up the new rate.
Jamie9:42 AM
That makes sense. Thanks.
Show code
import { ChatMessage } from '@elirobinson/react/components/ai/ChatMessage';
import { ChatThread } from '@elirobinson/react/components/ai/ChatThread';

export default function Basic() {
  return (
    <ChatThread label="Support conversation">
      <ChatMessage variant="sent" avatar="JR" name="Jamie" timestamp="9:41 AM">
        My invoice still shows last month's rate. Did the new one take effect?
      </ChatMessage>
      <ChatMessage avatar="M" name="Assistant" timestamp="9:41 AM">
        It did, on the 1st. The invoice you're looking at covers the period before that, so it bills
        at the old rate. The next one picks up the new rate.
      </ChatMessage>
      <ChatMessage variant="sent" avatar="JR" name="Jamie" timestamp="9:42 AM">
        That makes sense. Thanks.
      </ChatMessage>
    </ChatThread>
  );
}

When to use it

ChatThread is the scrolling region a conversation lives in: a column of ChatMessage turns that announces new ones as they arrive. It's the region, not the transcript — it holds no state and keeps no message list. What it owns is the announcement contract and keeping the newest turn in view, which are the two parts that are easy to get wrong by hand.

It sits in the ai tier because it only means anything inside an assistant interaction. The tier answers "what is this for," not "how much is assembled here" — a component earns ai when you can't describe it without saying assistant, model, or streaming. A card that happens to render a model's answer is still a Card.

Announcing, and not announcing

announce defaults to true, which is right for a live conversation: aria-live="polite" means a turn that arrives while the reader is elsewhere still reaches them, at the next pause rather than mid-sentence.

Pass announce={false} for a thread that isn't live — a closed conversation, a saved transcript, a replay in a history view. That sets aria-live="off", so mounting a hundred past turns doesn't queue a hundred announcements. Rendering history is the case where the default is wrong.

Following the newest turn

followNewMessages defaults to true: as the thread grows, the newest turn is scrolled into view. This isn't a preference the system left to you, because a live region that announces a turn and then renders it below the fold is a defect for exactly the readers the live region is for.

It only follows a reader who is already at the bottom. Scroll up to re-read an earlier turn and the thread stays where you put it, however many turns arrive behind you; return to the bottom and it picks up again. "At the bottom" allows 32px of slack, which is enough that a zoomed page's fractional scroll offsets still count as pinned and far less than the ~60px it takes to scroll back past a single turn.

It never animates. The scroll is an assignment, not a smooth transition — there is no motion to reduce, so nothing here needs a prefers-reduced-motion branch. The component sets scroll-behavior: auto on itself for that reason: the property inherits, and an html { scroll-behavior: smooth } in your reset would otherwise turn every arriving turn into an animation inside a live region.

Pass followNewMessages={false} to own scrolling yourself — for a jump-to-a-quoted-turn view, or a thread that opens on an unread marker rather than at the end. The ref still forwards to the element, so you have everything the component has.

Props

PropTypeDefaultDescription
labelrequiredstringAccessible name for the log region. Required — no copy lives in the component.
announcebooleantrueDefault true. False opts a closed or replayed thread out of live announcement.
followNewMessagesbooleantrueKeep the newest turn in view as the thread grows. Default true. Only a reader already at the bottom is followed — scroll up to re-read an earlier turn and the thread stays put until you return. The scroll is instant, never animated, and moves nothing in the focus order, so a composer keeps the caret while turns arrive behind it.

Also accepts all HTMLAttributes<HTMLDivElement> props.

Accessibility

  • Renders role="log" with aria-live="polite" and aria-relevant="additions text". The text half matters for assistant output: a turn that grows token by token is a text change inside an existing node, not an addition, and additions alone would announce the empty turn and then go quiet.
  • label is required and becomes aria-label. No copy lives inside the component, so the region's name is always the product's — and a page with more than one thread has two distinguishable regions rather than two unnamed logs.
  • The region scrolls (overflow-y: auto), so it needs to be keyboard scrollable. If it isn't focusable through its content, give it tabIndex={0} yourself; the component doesn't add one, because a region full of focusable turns doesn't need it and a stray tab stop is its own problem.
  • Keyboard: none of its own — ChatThread renders no controls. Anything focusable comes from the turns you put inside it.
  • Following the newest turn moves nothing in the focus order: setting scrollTop is not a focus change, so a composer below the thread keeps the caret and the typed text while turns arrive above it.
  • The ref forwards to the outer <div> — the component holds its own reference internally and merges the two, so what you receive is the same element it scrolls.

Do

  • Give every thread a label that says which conversation it is.
  • Pass announce={false} when you are rendering history rather than a live stream.
  • Keep the turns inside it ChatMessage elements, so the avatar column lines up.
  • Leave followNewMessages on unless you are deliberately opening somewhere other than the end.

Don't

  • Mount a long replayed transcript with announce left at its default.
  • Wrap the thread in another live region; nesting them announces twice.
  • Expect ChatThread to hold or order messages — it renders the children you give it.
  • Use it as a generic scrolling list; outside an assistant surface, the log role is wrong.

Product theming

ChatThread draws only system tokens, but it's the container for turns that read the optional product token layer — the --product-* convention documented in docs/agents/product-token-layer.md. Every read there falls back to a system token, so a product that declares none of it gets the system palette unchanged.