Chat & AI interfaces
Rules for chat screens and AI assistants: scroll without pulling the reader away, stream text without flooding screen readers, give errors a retry path, and always show what the AI is doing.
Anatomy of a chat screen
A chat screen in Yugen UI is built from five parts, each with one responsibility.
| Part | Responsibility |
|---|---|
| MessageScroller | The scroll area: sticks to the bottom, new-message button, loading older messages, live region, End key. |
| MessageBubble | One message: side (incoming / outgoing), sender, time, send status, reactions, streaming. Groups consecutive messages by sender. |
| MessageDaySeparator | A date marker between messages. It stays readable text, not role=separator. |
| TypingIndicator | Three “typing” dots before any content arrives; role=status. |
| ChatComposer | An auto-growing input: Enter sends, Shift+Enter adds a line, busy + a Stop button for AI. |
Scrolling rules
Keep users where they are reading. Only scroll automatically when you are sure they want to see the latest message.
| Situation | Behavior |
|---|---|
| At the bottom, a new message or streamed text arrives | Stick to the bottom so new content stays in view (a ResizeObserver also tracks images that finish loading and the composer growing). |
| Scrolled up, a new message arrives | No jump. A floating “N tin nhắn mới ↓” (N new messages) button appears; it scrolls down, and the count resets when the bottom is reached. |
| The user sends a message | Always scroll to the bottom. They need to see that their message went out. |
| Reaching the top of the list | onLoadOlder is called; older messages are inserted above without moving the reading point (scrollTop is compensated, overflow-anchor is off). |
| Keyboard | Focus the message area and press End to jump to the latest message; arrows / PageUp / PageDown scroll as usual. |
<MessageScroller
className="h-[60vh]"
onLoadOlder={loadOlder} // reaching the top loads more and keeps the reading position
hasOlder={cursor !== null}
loadingOlder={isLoading}
>
{messages.map((m) => (
<MessageBubble key={m.id} side={m.mine ? "outgoing" : "incoming"} author={m.author} time={m.time}>
{m.text}
</MessageBubble>
))}
</MessageScroller>Every child needs a stable key (the message id). MessageScroller compares the first and last keys between renders to tell older messages inserted at the top from new messages added at the end.
| Prop · Type | Default | Description |
|---|---|---|
| onLoadOlder() => void | Promise<void> | Default: — | Return a Promise so it is not called again while loading. |
| hasOlder / loadingOlderboolean | Default: — | Turn off when the history runs out; spinner at the top of the list. |
| bottomThresholdnumber | Default: 48 | How many px from the bottom still count as “at the end”. |
Streaming & typing indicator
AI responses arrive in chunks. The UI has to show progress without layout jumps and without making screen readers stutter.
- Show TypingIndicator right after sending, before the first token, so the user knows the request was received.
- When the first token arrives, replace TypingIndicator with a
streamingMessageBubble in the same position. - A blinking caret means more is coming; with reduced motion it stays still.
- Allow stopping:
ChatComposer busy onStop. Keep the partial answer, with a “Stopped” note.
// 1. Sent, no tokens yet → TypingIndicator
{waiting && <TypingIndicator key="typing" author="Sakura Bot" />}
// 2. First token arrives → replace it with a streaming bubble (same position, no layout jump)
<MessageBubble key={answer.id} author="Sakura Bot" streaming={!answer.done}>
{answer.text}
</MessageBubble>
// 3. Done → streaming={false}: the caret disappears and the live region reads the whole answer once
// While waiting: <ChatComposer busy onStop={abortController.abort} … />Errors & retry
A send error belongs to the message that failed, not to a toast that slides away.
- Keep the content of the failed message; the red border and message come with an icon, not just a color change.
- A “Thử lại” (Retry) button sits right under the message; the error message uses role=alert so it is read at once.
- AI errors (quota exceeded, content blocked) are a normal incoming message that explains why and what the user can do.
- A global connection loss gets one MessageBar above the list, not an error repeated on every message.
Live regions & screen readers
By default role=log announces every change. With streamed text, that means reading a few words at a time. Yugen separates what is displayed from what is announced.
<div role="log" aria-label="Conversation" aria-live="off" tabIndex={0}>
…messages…
</div>
{/* Separate announcement region: only receives complete incoming messages */}
<div aria-live="polite" aria-atomic="true" className="sr-only">Sakura Bot: Set data-brand…</div>| Event | Announced | How |
|---|---|---|
| Complete incoming message | “Sender name: content” | MessageScroller's own polite region. |
| Message still streaming | Not yet | Read once when streaming becomes false. |
| Your own message | Not read | The user just typed it. |
| Older messages loaded | Not read | They are not new. |
| Typing | “Sakura Bot đang soạn tin…” (Sakura Bot is typing…) | TypingIndicator role=status. |
| Send failed | The error message | role=alert under the message. |
The message area has tabIndex=0 so keyboard users can scroll it, and a name (label). The send status is an icon with hidden text such as “Đã xem” (Seen), not just a color.
Keyboard in the composer
It follows the conventions of common chat apps.
| Key | Action |
|---|---|
| Enter | Send (when there is content and it is not busy). |
| Shift+Enter | New line. |
| Typing with Telex / VNI / an IME | Enter only confirms the text being composed and does not send (checks isComposing). |
| Auto-growing input | Up to maxRows lines (default 6), then it scrolls inside. |
| After sending | The input is cleared and keeps focus for the next message. |
Transparency with Trace
An AI assistant does many steps out of sight: searching, calling tools, reading documents. Put a collapsed Trace in the answer so users can check them.
A Trace inside a message should use size="small", sit in a bubble with appearance="plain", and come before the answer. See also Process transparency with Trace.
AI answers: Markdown, sources, reasoning
The AI Kit pairs with MessageScroller to build a complete answer: collapsed reasoning, streaming Markdown, source chips and a composer with attachments.
Check the brand rules and the WCAG contrast thresholds.
Use brand for primary actions and selected states; text needs a contrast of ≥ 4.5:1.
- Don't cover large backgrounds with brand
- Always pair status with an icon and text
Sources (2)
Reasoningcomes before the answer: it is open while the model is thinking and collapses on its own with “Đã suy luận trong 12s” (Reasoned for 12s).StreamingMarkdowntakes a growing string: no stray**or unfinished fences, finished blocks are not re-rendered, and links are limited to http / https / mailto. InsideMessageBubble streaming, setcaret={false}.[1]in the Markdown pluscitationsbecomes aCitationchip;CitationListlists the sources below.PromptInputreplacesChatComposerwhen you need attachments (button, drag and drop, pasted images), a model picker and a Stop button.
Do and don't
- Stick to the bottom only when the user is at the bottom; otherwise use the “new messages” button.
- Show the typing indicator as soon as a message is sent, and a Stop button for long answers.
- Attach the error and the retry button to the message that failed.
- Show the steps the agent took with a Trace, collapsed by default.
- Jumping the list to the bottom while the user is reading older messages.
- Letting screen readers read streamed text chunk by chunk.
- Deleting the failed message or the composer content when sending fails.
- Hiding that the AI called tools or read the user's data.