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
Conversationfrom@elirobinson/ai-elements. It is backed byuse-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. Theannounceprop has no direct equivalent — write the attributes instead:aria-live="polite"andaria-relevant="additions text", oraria-live="off"for a replayed thread. This component still works and is removed on the next major.
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
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | Accessible name for the log region. Required — no copy lives in the component. |
announce | boolean | true | Default true. False opts a closed or replayed thread out of live announcement. |
followNewMessages | boolean | true | Keep 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"witharia-live="polite"andaria-relevant="additions text". Thetexthalf matters for assistant output: a turn that grows token by token is a text change inside an existing node, not an addition, andadditionsalone would announce the empty turn and then go quiet. labelis required and becomesaria-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 ittabIndex={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 —
ChatThreadrenders no controls. Anything focusable comes from the turns you put inside it. - Following the newest turn moves nothing in the focus order: setting
scrollTopis 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.