The five pieces
`ChatThread` is the transcript container, `ChatMessage` is a single turn, `ChatInput` is the composer, `ChatCodeBlock` renders fenced code with a copy affordance, and `TypingIndicator` covers the wait between sending and the first token.
That last one is not decoration. The gap between submitting a prompt and the first streamed token is the moment users assume something broke. An explicit indicator is the difference between 'thinking' and 'this is broken', and it costs nothing.
Model output must never become markup
This is the single most important rule in the whole component set. Message content is always a `ReactNode`, never an HTML string, and there is no `dangerouslySetInnerHTML` anywhere in the library. Model output therefore cannot become markup.
The risk is concrete. Models can be prompted into emitting HTML, and if a user can influence the prompt — which in a chat product they can by definition — then rendering model output as HTML is a cross-site scripting vector with extra steps. If you render Markdown from a model, sanitise it and use an allowlist; treat the model as untrusted input, because it is.
Announcing new turns without flooding
The transcript is a `role="log"` with `aria-relevant="additions"`, so new turns are announced as they arrive and prior content is not re-read. `role="log"` is the correct choice over `role="alert"` because a chat message is informational, not urgent — an alert role would interrupt whatever the user was doing.
Streaming creates a specific hazard here: if every token triggers an announcement, a screen reader reads the message letter by letter, which is worse than useless. The message should be announced when it settles, not on every chunk.
Scroll behaviour that respects intent
Auto-scrolling to the newest message is right when the user is at the bottom and wrong when they have scrolled up to read something. Yanking them back down mid-sentence is one of the most consistently irritating bugs in chat interfaces.
The rule is to auto-scroll only when already pinned to the bottom, and to show a 'jump to latest' affordance otherwise. Track whether the user is at the bottom before content arrives, not after, because appending content changes the measurement.
Code blocks in a transcript
Code is the most-copied content in an AI chat, so `ChatCodeBlock` exists as its own component with a copy affordance rather than being styling on a `<pre>`. Copy is the primary action on a code block and should not require a text selection.
Long lines need a horizontal scroll container of their own so they do not force the whole transcript to scroll sideways — the same rule that applies to code in any responsive layout, and one that chat interfaces break constantly.
Language labelling is worth doing even without syntax highlighting. A code block announced as 'code' tells a screen reader user nothing about what they are about to hear, and a visible language label helps every user decide whether the snippet is the one they wanted before they read it.
The composer
Enter sends and Shift+Enter inserts a newline is the convention users expect, and deviating from it is a bad idea no matter how reasonable the alternative seems. The input should also grow with content up to a cap, then scroll internally.
Disable submission while a response streams, or handle concurrent turns explicitly. Two in-flight responses appending to the same transcript interleave into nonsense, and this is one of the more common bugs in hand-rolled chat interfaces.
Keep the composer focused after sending. A user who types a prompt, presses Enter and then has to click back into the input before typing again will notice the friction on every single turn, even if they never work out what is annoying them.
Key takeaways
- TypingIndicator is functional, not decorative — it fills the gap where users assume the app broke
- Message content is ReactNode, never an HTML string; there is no dangerouslySetInnerHTML in the library
- Treat model output as untrusted input — a user who shapes the prompt can shape the output
- role="log" with aria-relevant="additions" announces new turns without re-reading the transcript
- Announce on settle, not per token, or a screen reader reads streaming output letter by letter
- Auto-scroll only when already pinned to the bottom, and disable submit while a response streams
Conclusion
A chat interface looks like a list of messages and behaves like a real-time system. The parts that decide whether it feels finished are the ones with no visual representation: announcement timing, scroll intent, and the hard rule that model output never becomes markup.
Enjoyed this article?

Vivek Kumar Singh
Technical Expert · Full Stack Cloud Engineer · Tokyo, Japan