Chat
Chat is the AI Tutor: a panel with a welcome state, a message log, web sources, a thinking indicator, an answer-mode toggle and a prompt box. It never calls an AI; your app passes the messages and handles sending.
Follows the live learner app (owner, 2026-10-14), restyled with Nuton tokens. Beta until design confirms the deviations listed under States and interactions.
Preview
Step through the live screens. Type in the composer and press Enter to move to Thinking; press Tab in an empty composer to use the suggestion.
AI Tutor (playground)
Here to help
<ChatPanel
avatar={<TutorMark />}
online
actions={headerActions}
footer={<>
<ChatModeToggle value={mode} onValueChange={setMode} />
<ChatComposer
onSend={send}
placeholder="Ask me anything about Business structures"
/>
</>}
>
<ChatMessage from="user" quote={highlight} quoteSource="Chapter 2 · Business structures">Can you explain this with a real example?</ChatMessage>
<ChatMessage
actions={<ChatMessageActions copyText={answerText} />}
>
{answer}
</ChatMessage>
</ChatPanel>Installation
Availability
@nuton/ui and @nuton/tokens are private workspace packages and aren't published yet (D4, D12). Inside the Nuton monorepo, add them as workspace dependencies.
pnpm add @nuton/ui @nuton/tokens @remixicon/reactnpm install @nuton/ui @nuton/tokens @remixicon/reactyarn add @nuton/ui @nuton/tokens @remixicon/reactimport '@nuton/tokens/tokens.css'
import '@nuton/ui/styles.css'No provider is needed. The panel fills its container, so give the container a height (for example the column beside the lesson). Your app supplies the tutor mark or avatar image.
Usage
import {
ChatComposer, ChatMessage, ChatMessageActions, ChatModeToggle, ChatPanel, ChatSources, ChatThinking, ChatWelcome,
} from '@nuton/ui'
function Tutor() {
const [messages, setMessages] = useState([])
const [mode, setMode] = useState('grounded')
const [thinking, setThinking] = useState(false)
async function send(text: string) {
setMessages((m) => [...m, { id: crypto.randomUUID(), from: 'user', text }])
setThinking(true)
const reply = await askYourAiService(text, mode) // your code, not part of @nuton/ui
setThinking(false)
setMessages((m) => [...m, { id: crypto.randomUUID(), from: 'assistant', ...reply }])
}
return (
<ChatPanel
avatar={<img src={tutorMark} alt="" />}
online
footer={<>
<ChatModeToggle value={mode} onValueChange={setMode} />
<ChatComposer onSend={send} sendDisabled={thinking} placeholder="Ask me anything about Business structures" />
</>}
>
{messages.length === 0 && <ChatWelcome title="Hey Ada 👋" description="What's not clicking yet?" />}
{messages.map((m) => (
<ChatMessage
key={m.id}
from={m.from}
sources={m.sources && <ChatSources sources={m.sources} />}
actions={m.from === 'assistant' && <ChatMessageActions copyText={m.text} />}
>
{m.text}
</ChatMessage>
))}
{thinking && <ChatThinking variant={mode === 'research' ? 'research' : 'thinking'} />}
</ChatPanel>
)
}- The components only display. Your app stores the messages, calls its AI service in
onSend, and decides what Retry does. - Show
ChatThinkingwhile you wait, and add the reply as one message when it is complete. If you stream text in, setbusyon the panel until it finishes, so screen readers hear it once. - Show
ChatWelcomewhile there are no messages, andloadingwhile the history loads. - Assistant messages accept rich content (rendered markdown, lists, code, maths). Sanitise model output before rendering HTML.
- Every string has an English default. Pass
labels(ortitle,subtitle,placeholder) to localise.
Variants and sizes
The set has ten parts. Each matches a piece of the live AI Tutor.
Welcome
Hey Ada 👋
I've read this whole chapter with you. What's not clicking yet?
<ChatWelcome mark={<TutorMark />} title="Hey Ada 👋" description="I've read this whole chapter with you. What's not clicking yet?" />Messages
The learner's message is a bubble on the right. A question about highlighted lesson text carries the quote in a card at the top of the bubble. The tutor's answer has no bubble; it reads as prose.
<ChatMessage from="user" quote={highlight} quoteSource="Chapter 2 · Business structures">
Can you explain this with a real example?
</ChatMessage>
<ChatMessage from="user">What about partnerships?</ChatMessage>
<ChatMessage actions={<ChatMessageActions copyText={answerText} />}>{answer}</ChatMessage>Sources from the web
Research answers list the pages they used. Each link opens in a new tab and says so to screen readers. Without a title, the link shows the site name.
<ChatMessage sources={<ChatSources sources={[
{ href: 'https://www.gov.uk/business-legal-structures', title: 'Set up a business: legal structures (GOV.UK)' },
{ href: 'https://en.wikipedia.org/wiki/Sole_proprietorship', title: 'Sole proprietorship (Wikipedia)' },
]} />}>
{answer}
</ChatMessage>Message actions
Live has one action: Copy. Its tooltip says “Copy”, then “Copied!” with a green check for two seconds. Good and Bad response toggles are optional (showFeedback) and off by default.
<ChatMessageActions copyText={answerText} />
<ChatMessageActions copyText={answerText} showFeedback onFeedbackChange={save} />
<ChatActionButton label="Regenerate"><RiRefreshLine /></ChatActionButton>Thinking
<ChatThinking />
<ChatThinking variant="research" />Answer mode
Grounded answers only from the learner's material. Research also searches the web; it is slower and costs more. Each option has a tooltip with that hint, also read as its description. Pass options for other modes.
<ChatModeToggle value={mode} onValueChange={setMode} />
<ChatModeToggle options={[{ value: 'short', label: 'Short' }, { value: 'detailed', label: 'Detailed' }]} labels={{ group: 'Answer length' }} />Composer
A pill-shaped field that grows to 140px, then scrolls. The topSlot holds the “Asking about” card. While the field is empty, a suggestion shows as the placeholder; Tab or → fills it in, and a “Tab ⇥” chip says so (hidden on small screens).
Asking about
While a sole trader works alone, a company follows a plan agreed by its shareholders.
<ChatComposer
placeholder="Ask me anything about Business structures"
suggestion="Explain this chapter with an example"
topSlot={highlight && <ChatContextCard onRemove={() => setHighlight(null)}>{highlight}</ChatContextCard>}
onSend={send}
/>Measurements
| Part | Live value | Nuton |
|---|---|---|
| Panel | 1px border, radius 20; compact has neither | Stroke/Soft-200, radius-20 |
| Header | 66 tall (full view 60), padding 0 16, bottom divider; avatar 28 with a 10px online dot | Label/Small name, Paragraph/X Small subtitle in Text/Sub-600; Button lighter sm icon-only (36) |
| Log | Padding 16, gap 24. Full view: centred at 720, padding 32 / 24, gap 28 | --nu-chat-panel-measure, --nu-chat-panel-gap |
| Welcome | Mark 52, greeting 24, subline max 270, 8 below | Title/H5, Paragraph/Small Text/Sub-600 |
| User bubble | Max 90%, radius 20 with a 4 corner at the bottom end, padding 12 / 16 | Bg/Soft-100, Paragraph/Small |
| Highlight quote | Radius 16, padding 10 / 12; 12 above the divider and 12 below | Bg/White, Subheading/2X Small label, Paragraph/X Small text |
| Assistant answer | No bubble, 24 end padding | Paragraph/Small Text/Strong-950 |
| Sources | Radius 12, padding 10 / 12, links gap 4, 12px icons | Bg/Weak-50, State/Primary/Base links, 24 tall |
| Copy button | 28 × 28, radius 8, 16px icon | Bg/Weak-50 → Bg/Soft-200, Icon/Sub-600 |
| Thinking | 20px icon, gap 10, 2s per turn | Paragraph/Small Text/Sub-600 |
| Answer mode | Pill, padding 2, gap 2; options padding 4 / 10 (28 tall) | Bg/Weak-50, Label/X Small; selected Bg/White + Regular Shadow/X Small |
| Composer | Radius 28, padding 12 10 12 20, gap 8, grows to 140; send 36 round | Custom Shadows/Chat; Button filled sm icon-only |
| Context card | Radius 12, padding 10 / 12, 2 lines, remove button | Bg/Weak-50; remove button 24 (code-only) |
States and interactions
The live screens, then the states of the parts.
AI Tutor (welcome example)
Here to help
Hey Ada 👋
I've read this whole chapter with you. What's not clicking yet?
AI Tutor (conversation example)
Here to help
AI Tutor (thinking example)
Here to help
AI Tutor (research example)
Here to help
AI Tutor (error example)
Here to help
AI Tutor (loading example)
Here to help
aria-busy until they go.sendDisabled: Nuton disabled colours, not 30% opacity.Asking about
While a sole trader works alone, a company follows a plan agreed by its shareholders.
onRemove it has a 24px remove button, and focus returns to the text box.Component tokens
| Token | Value | Used for |
|---|---|---|
--nu-chat-panel-header-height | 66px (full view 60px) | Header height |
--nu-chat-panel-measure | 720px | Full-view conversation width |
--nu-chat-panel-gap | space-6 (full view 28px) | Space between messages |
--nu-chat-bubble-max | 90% | User bubble width |
--nu-chat-action-bg / -hover | Bg/Weak-50 / Bg/Soft-200 | Copy button fill |
--nu-chat-thinking-turn | 2s | One turn of the sparkle |
--nu-chat-composer-radius | 28px | Composer field corners |
--nu-chat-composer-send | 36px | Send button size (centres the text line) |
Where this differs from the live app
- Grey text (#a4a4a4: subtitle, placeholder, labels, “Thinking...”) uses Text/Sub-600. #a4a4a4 is 2.49:1 (C1, D7).
- “Thinking...” doesn't pulse; only the icon does. Fading text drops below 4.5:1.
- Composer focus adds a dark border and halo; the live grey border alone is 1.5:1.
- The send button uses the Button disabled colours instead of 30% opacity.
- The user's text is start-aligned inside the right-aligned bubble; long right-aligned text is hard to read.
- Red error text and the copied check use red-700 (C6) and green-700.
- Sizes snap to Nuton type styles: 13px text uses Paragraph/X Small (12), the greeting Title/H5 (24/32), 11px labels Subheading/2X Small without extra tracking, the “Tab ⇥” chip is Kbd.
- Header buttons use the 20px icons of Button sm (live 15px).
- Source links are 24px tall and the context card's remove button is 24px (WCAG 2.5.8).
- Every icon button has a tooltip of its name, which isn't linked as a description.
- The welcome glow is a Bg/Weak-50 gradient, removed in forced colours.
- The full view is the maximized state of
ChatDockon the web, and a full-width sheet on a phone.
Composition examples
The AI tutor beside a lesson: Chat dock
The tutor is a ChatDock: one element that is the Ask Nuri pill (Chat launcher), the compact panel and, on the web, a maximized panel; a sheet from the bottom centre on a tablet or phone. It holds a ChatPanel for its content, stays mounted so the conversation and the draft never reset, and replaces the earlier docked column with a separate full-view dialog.
- Render the conversation in one panel at a time. Two logs with the same messages would be announced twice.
- Closing the panel is not a layout hack:
ChatDockturns it back into the pill and keeps everything in it.
Ask about selected text
Select words in the lesson. A Selection toolbar appears above them (Figma 575:19481). Ask AI Tutor puts the text in a ChatContextCard and moves focus to the composer; Add to notes saves it to the notes list and announces “Added to notes”. With a keyboard, press Alt+F10 (Option+F10 on a Mac) after selecting to reach the toolbar.
- Move focus to the composer in
onAskAI, so people can type their question straight away. The toolbar leaves focus where the action put it. - The live app opens an explanation popover from its own highlight toolbar (“Ask Nuton”, “Explain”, “Simpler”…). Pass those as
actionsif you need them.
API reference
ChatPanel
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | ChatWelcome, ChatMessage and ChatThinking, in order. |
footer | ReactNode | — | Pinned under the log: usually ChatModeToggle and ChatComposer. |
title | ReactNode | labels.title | Name in the header (“AI Tutor”). Names the panel region. |
titleAs | ElementType | 'h2' | Heading element for the name. |
subtitle | ReactNode | labels.subtitle | Line under the name (“Here to help”). null hides it. |
avatar | ReactNode | — | 28px image before the name. Decorative. |
online | boolean | false | Green dot on the avatar, named “Online”. |
actions | ReactNode | — | Header buttons, e.g. expand and collapse. |
view | 'panel' | 'full' | 'panel' | Full: 60px header, conversation centred at 720px. |
compact | boolean | false | No border or radius, inside a Sheet or Modal. |
loading | boolean | false | Three Skeleton rows instead of children; the log is busy. |
busy | boolean | false | Sets aria-busy on the log while you stream a reply. |
labels | Partial<ChatPanelLabels> | — | title 'AI Tutor', subtitle 'Here to help', log 'Conversation', online 'Online', loading 'Loading conversation'. |
…section props | HTMLAttributes | — | Go to the <section>. The ref points at it. |
ChatWelcome
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | ReactNode | — | The greeting. |
titleAs | ElementType | 'h3' | Heading element for the greeting. |
description | ReactNode | — | The line under the greeting. |
mark | ReactNode | — | 52px mark or avatar. Decorative. |
children | ReactNode | — | Extra content under the description. |
ChatMessage
| Prop | Type | Default | Description |
|---|---|---|---|
from | 'user' | 'assistant' | 'assistant' | User: a bubble on the right. Assistant: prose. |
children | ReactNode | — | The content. Assistant messages accept rich content. |
quote · quoteSource | ReactNode | — | User messages: the highlighted lesson text and where it comes from, in a card at the top of the bubble. |
status | 'error' | — | Red text; with onRetry, a Retry button. |
onRetry | () => void | — | Called by Retry. |
sources | ReactNode | — | Usually ChatSources, under the answer. |
actions | ReactNode | — | Usually ChatMessageActions. |
author | string | — | Sender name read before the message. Defaults to labels.user or labels.assistant. |
labels | Partial<ChatMessageLabels> | — | user 'You', assistant 'AI Tutor', quote 'Question about highlighted lesson text', retry 'Retry'. |
ChatSources
| Prop | Type | Default | Description |
|---|---|---|---|
sourcesrequired | { href; title?; id? }[] | — | The pages. Without a title the link shows the host name. Renders nothing when empty. |
labels | Partial<{ title; newTab }> | — | title 'Sources from the web', newTab '(opens in a new tab)'. |
ChatMessageActions and ChatActionButton
| Prop | Type | Default | Description |
|---|---|---|---|
copyText | string | — | Written to the clipboard when Copy is pressed. |
onCopy | () => void | Promise | — | Called on Copy. A rejected promise doesn’t show “Copied!”. |
copiedDuration | number | 2000 | How long the check and “Copied!” show, in ms. |
showCopy · showFeedback | boolean | true · false | Copy is on; Good and Bad response toggles are off (not on live). |
feedback · defaultFeedback | 'good' | 'bad' | null | null | Controlled or uncontrolled feedback. |
onFeedbackChange | (feedback) => void | — | Called with the new feedback; null when cleared. |
appearance | 'lighter' | 'stroke' | 'ghost' | 'lighter' | Button appearance. |
children | ReactNode | — | Extra ChatActionButtons after the built-in ones. |
labels | Partial<ChatMessageActionsLabels> | — | group 'Response actions', copy 'Copy', copied 'Copied!', good 'Good response', bad 'Bad response'. |
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | Accessible name of the icon-only button; also its tooltip. |
childrenrequired | ReactNode | — | The 16px icon. |
tooltip | ReactNode | false | label | Tooltip text, or false for none. Never linked as a description. |
appearance | 'lighter' | 'stroke' | 'ghost' | 'lighter' | Fill style. |
active | boolean | false | Dark “ready” look without toggle semantics. |
pressed | boolean | — | Makes it a toggle: sets aria-pressed and the dark look. |
…button props | ButtonHTMLAttributes | — | type defaults to "button". The ref points at the button. |
ChatThinking
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'thinking' | 'research' | 'thinking' | Sparkle, or globe while the web is searched. |
labels | Partial<{ thinking; research }> | — | 'Thinking...' and 'Searching the web…'. |
ChatModeToggle
| Prop | Type | Default | Description |
|---|---|---|---|
options | ChatModeOption[] | CHAT_MODE_OPTIONS | { value, label, hint?, icon? }. Default: Grounded and Research. |
value · defaultValue | string | first option | Controlled or uncontrolled value. |
onValueChange | (value) => void | — | Called when the selection changes. |
disabled | boolean | false | Disables every option. |
compact | boolean | false | Only the chosen mode shows its label; the others show their icon (still named for screen readers). Give every option an icon. |
size | 'sm' | 'md' | 'sm' | Control height 36 or 44px (44 for a toolbar of 44px buttons). |
labels | Partial<{ group }> | { group: 'Answer mode' } | Names the radio group, unless you pass aria-labelledby. |
ChatComposer and ChatContextCard
| Prop | Type | Default | Description |
|---|---|---|---|
onSend | (text: string) => void | — | Called with the trimmed text on Enter or the send button. Uncontrolled composers clear. |
value · defaultValue | string | — | Controlled or uncontrolled text. |
onValueChange | (text: string) => void | — | Called on every change. onChange still fires with the event. |
sendDisabled | boolean | false | Disables sending while a reply is written. Typing still works. |
sendOnEnter | boolean | true | Enter sends, Shift+Enter adds a line. Never while an input method is composing. |
layout | 'inline' | 'stacked' | 'inline' | inline: one pill row (the tutor panel). stacked: text on top, a toolbar row under it with tools at the start and send at the end (Figma Chat textbox 4564:39146). |
addFileIcon | ReactNode | — | Replaces the add-file icon (a Remix icon), e.g. a plus. |
sendIcon | ReactNode | — | Replaces the send icon (a Remix icon), e.g. an arrow pointing right. |
maxHeight | number | 140 | The text box grows to this height in px, then scrolls. |
suggestion | string | — | Shown as the placeholder while empty; Tab or → fills it in. |
onSuggestionAccept | (text: string) => void | — | Called when the suggestion is filled in. |
topSlot | ReactNode | — | Above the field, usually a ChatContextCard. |
contained | boolean | false | Draws one border around the topSlot and the field, so a quoted passage, suggestion chips and the question box read as one box. Needs a topSlot. Code-only (C102). |
tools | ReactNode | — | Extra ChatActionButtons before the text. Off by default. |
onAddFile · onFilesSelected | () => void · (files) => void | — | Show the add-file button. Off by default. |
accept · multiple | string · boolean | multiple: true | Passed to the file picker. |
attachments · onRemoveAttachment | ChatComposerAttachment[] · (id) => void | — | Compact file cards above the field. Uploading files block sending. |
invalid · disabled · required | boolean | — | Inside a Field, these come from the Field. |
labels | Partial<ChatComposerLabels> | — | input 'Message', placeholder 'Ask me anything', send 'Send message', suggestionKey 'Tab', suggestionHint (s) => …, addFile, attachments, removeAttachment. |
…textarea props | TextareaHTMLAttributes | — | placeholder, name, maxLength and others go to the textarea. The ref points at it. |
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | What the step was, e.g. “Thought Process” or “Searched the web”. |
icon | ReactNode | — | A Remix icon before the label, such as a globe. Decorative. |
children | ReactNode | — | What the step found or did. Leave it out for a plain label with nothing to open. |
open / defaultOpen / onOpenChange | boolean / boolean / (open) => void | — | Controlled or initial open state. The row is a button with aria-expanded; the chevron turns down when open. |
| Prop | Type | Default | Description |
|---|---|---|---|
question | string | — | The clarifying question. |
options | string[] | — | The answers, plus an automatic free-text “Something else”. |
multiple | boolean | false | true: checkboxes and a Next button for several answers. false: pick one and it is sent. |
defaultPicked | string[] | — | Options already ticked when the card first shows. |
step | { current, total } | — | Shows “1 of 2”. |
onSubmit / onSkip / onClose | (answers) => void / () => void / () => void | — | Answer, skip, or dismiss the question. |
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | The highlighted text, clamped to two lines. |
onRemove | () => void | — | Shows the remove button. Focus then moves to the composer’s text box. |
appearance | 'card' | 'inline' | 'card' | inline drops the card’s border and sits the passage on a Bg/Weak-50 fill, for use inside a contained composer. Code-only (C102, C103). |
labels | Partial<{ label; remove }> | — | label 'Asking about', remove 'Remove highlight context'. |
Accessibility
Keyboard
| Key | Behaviour |
|---|---|
| Tab | Moves through header buttons, the log, message actions, sources, the answer mode and the composer. In an empty composer with a suggestion: fills it in |
| → | In an empty composer with a suggestion: fills it in |
| Enter | In the composer: sends. On a button: activates it |
| Shift / Enter | In the composer: adds a line |
| ← / → / ↑ / ↓ | In the answer mode: selects the previous or next mode |
| Home / End | In the answer mode: selects the first or last mode |
| Esc | Closes a tooltip; in the full view, minimizes it |
Screen readers
The panel is a region named by its title. The log has role="log" and aria-live="polite": each new message is read once, prefixed by the sender (“You:”, “AI Tutor:”). Thinking and “Copied!” are read by the log, not by nested live regions, so they aren't doubled; on their own they are polite statuses. The answer mode is a radio group; each option's hint is its description. Source links say they open in a new tab. The suggestion is read as the text box's description.
Focus
After sending, focus stays in the text box. Removing the context card returns focus to the text box. The composer shows a dark border and halo while typing; every button and the log show a visible focus style. Minimize returns focus to the expand button.
Contrast and colour
Messages use Text/Strong-950; the subtitle, placeholder, labels and “Thinking...” use Text/Sub-600 instead of live #a4a4a4 (2.49:1). Errors are red-700 (C6). The selected mode is white with a shadow and stronger text, not colour alone. In forced colours, boxes get borders and the selected mode uses Highlight.
Touch and pointer
Copy is 28 × 28, mode options 28 tall, source links and the remove button 24: all at or above 24 × 24 (WCAG 2.5.8). Header and send buttons are 36. Tooltips aren't shown on touch, so every icon button also has a name.
You must: add replies as complete messages (or set busy while streaming), render the conversation in one panel at a time, give extra icon buttons a label, and sanitise any HTML you render from a model.
Guidelines and related components
Use Chat for
- The AI Tutor beside a lesson, note or space
- Questions about a highlighted passage
- Research answers that cite the web
Don't use Chat for
- Comments or messages between people
- Single-field forms with no conversation (use Textarea)
- Notifications (use a toast)
Do and don't
Sources from the web