Yugen UI

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.

Today
Sakura Bot
Hi! How can I help?
You
How do I switch the brand to blue?
Seen
Sakura Bot is typing…
PartResponsibility
MessageScrollerThe scroll area: sticks to the bottom, new-message button, loading older messages, live region, End key.
MessageBubbleOne message: side (incoming / outgoing), sender, time, send status, reactions, streaming. Groups consecutive messages by sender.
MessageDaySeparatorA date marker between messages. It stays readable text, not role=separator.
TypingIndicatorThree “typing” dots before any content arrives; role=status.
ChatComposerAn 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.

SituationBehavior
At the bottom, a new message or streamed text arrivesStick 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 arrivesNo 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 messageAlways scroll to the bottom. They need to see that their message went out.
Reaching the top of the listonLoadOlder is called; older messages are inserted above without moving the reading point (scrollTop is compensated, overflow-anchor is off).
KeyboardFocus 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 · TypeDefaultDescription
onLoadOlder() => void | Promise<void>Default: —Return a Promise so it is not called again while loading.
hasOlder / loadingOlderbooleanDefault: —Turn off when the history runs out; spinner at the top of the list.
bottomThresholdnumberDefault: 48How 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.

You
How do I change the brand?
  • 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 streaming MessageBubble 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.

You
Please send me the new price list.
Seen
I need it before Friday.
  • 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>
EventAnnouncedHow
Complete incoming message“Sender name: content”MessageScroller's own polite region.
Message still streamingNot yetRead once when streaming becomes false.
Your own messageNot readThe user just typed it.
Older messages loadedNot readThey are not new.
Typing“Sakura Bot đang soạn tin…” (Sakura Bot is typing…)TypingIndicator role=status.
Send failedThe error messagerole=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.

KeyAction
EnterSend (when there is content and it is not busy).
Shift+EnterNew line.
Typing with Telex / VNI / an IMEEnter only confirms the text being composed and does not send (checks isComposing).
Auto-growing inputUp to maxRows lines (default 6), then it scrolls inside.
After sendingThe 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.

You
Summarize the 3 latest customer emails for me.
Sakura Bot

Completed 3 steps

Pending

Duration: 4.2s
  • Find emails from customers, Completed
    820ms
    mail.search
  • Read 3 emails, Completed
    1.1s
    mail.readparallel
  • Write the summary, Completed
    2.3s
    1.4k tokens
1. Hoa Dao Co. needs a quote for the Team plan before Oct 25. 2. Minh reports a sign-in bug in Safari. 3. Lan wants to move the demo to Thursday.

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)

  1. 1fluent2.microsoft.designFluent 2 — Color (opens in new tab)
  2. 2w3.orgWCAG 2.2 — Contrast (Minimum) (opens in new tab)
  • Reasoning comes 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).
  • StreamingMarkdown takes a growing string: no stray ** or unfinished fences, finished blocks are not re-rendered, and links are limited to http / https / mailto. Inside MessageBubble streaming, set caret={false}.
  • [1] in the Markdown plus citations becomes a Citation chip; CitationList lists the sources below.
  • PromptInput replaces ChatComposer when you need attachments (button, drag and drop, pasted images), a model picker and a Stop button.

Do and don't

Do
  • 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.
Don't
  • 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.