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.

  • Beta
  • Accessibility
  • Figma Live app (nuton.app learning-tutor), not Figma
  • packages/ui/src/components/Chat

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

You:

Question about highlighted lesson text

While a sole trader works alone, a company follows a plan agreed by its shareholders.
Chapter 2 · Business structures
Can you explain this with a real example?
AI Tutor:

Picture a bakery.

  • Sole trader: Ada owns it alone. She decides everything and is personally liable for its debts.
  • Company: the bakery becomes its own legal person. Shareholders agree its plan, and their risk is limited to what they invested.
tsx
<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.

bash
pnpm add @nuton/ui @nuton/tokens @remixicon/react
tsx
import '@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

tsx
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 ChatThinking while you wait, and add the reply as one message when it is complete. If you stream text in, set busy on the panel until it finishes, so screen readers hear it once.
  • Show ChatWelcome while there are no messages, and loading while 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 (or title, 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?

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.

You:

Question about highlighted lesson text

While a sole trader works alone, a company follows a plan agreed by its shareholders.
Chapter 2 · Business structures
Can you explain this with a real example?
You:
What about partnerships?
AI Tutor:

Picture a bakery.

  • Sole trader: Ada owns it alone. She decides everything and is personally liable for its debts.
  • Company: the bakery becomes its own legal person. Shareholders agree its plan, and their risk is limited to what they invested.

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.

AI Tutor:

Picture a bakery.

  • Sole trader: Ada owns it alone. She decides everything and is personally liable for its debts.
  • Company: the bakery becomes its own legal person. Shareholders agree its plan, and their risk is limited to what they invested.

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.

Thinking

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.

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.

Measurements

PartLive valueNuton
Panel1px border, radius 20; compact has neitherStroke/Soft-200, radius-20
Header66 tall (full view 60), padding 0 16, bottom divider; avatar 28 with a 10px online dotLabel/Small name, Paragraph/X Small subtitle in Text/Sub-600; Button lighter sm icon-only (36)
LogPadding 16, gap 24. Full view: centred at 720, padding 32 / 24, gap 28--nu-chat-panel-measure, --nu-chat-panel-gap
WelcomeMark 52, greeting 24, subline max 270, 8 belowTitle/H5, Paragraph/Small Text/Sub-600
User bubbleMax 90%, radius 20 with a 4 corner at the bottom end, padding 12 / 16Bg/Soft-100, Paragraph/Small
Highlight quoteRadius 16, padding 10 / 12; 12 above the divider and 12 belowBg/White, Subheading/2X Small label, Paragraph/X Small text
Assistant answerNo bubble, 24 end paddingParagraph/Small Text/Strong-950
SourcesRadius 12, padding 10 / 12, links gap 4, 12px iconsBg/Weak-50, State/Primary/Base links, 24 tall
Copy button28 × 28, radius 8, 16px iconBg/Weak-50 → Bg/Soft-200, Icon/Sub-600
Thinking20px icon, gap 10, 2s per turnParagraph/Small Text/Sub-600
Answer modePill, padding 2, gap 2; options padding 4 / 10 (28 tall)Bg/Weak-50, Label/X Small; selected Bg/White + Regular Shadow/X Small
ComposerRadius 28, padding 12 10 12 20, gap 8, grows to 140; send 36 roundCustom Shadows/Chat; Button filled sm icon-only
Context cardRadius 12, padding 10 / 12, 2 lines, remove buttonBg/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?

WelcomeNo messages yet: the greeting and a suggestion in the empty composer.

AI Tutor (conversation example)

Here to help

You:

Question about highlighted lesson text

While a sole trader works alone, a company follows a plan agreed by its shareholders.
Chapter 2 · Business structures
Can you explain this with a real example?
AI Tutor:

Picture a bakery.

  • Sole trader: Ada owns it alone. She decides everything and is personally liable for its debts.
  • Company: the bakery becomes its own legal person. Shareholders agree its plan, and their risk is limited to what they invested.
ConversationA question about highlighted text, and a Research answer with sources and Copy.

AI Tutor (thinking example)

Here to help

You:
Can you explain this with a real example?
ThinkingThe sparkle turns and pulses; with reduced motion it stays still. Read once as “Thinking...”.

AI Tutor (research example)

Here to help

You:
Can you explain this with a real example?
Research modeThe web is searched first: a globe and “Searching the web…”.

AI Tutor (error example)

Here to help

You:
Can you explain this with a real example?
AI Tutor:Something went wrong. Please try again.
Error and RetryThe message turns red-700 (C6) with a Retry button. Press it to see Thinking, then the answer.

AI Tutor (loading example)

Here to help

Loading conversation
Loading historyThree Skeleton rows; the log is aria-busy until they go.
Composer focus (code-only)Live only greys the border (1.5:1). Code uses a Stroke/Strong-950 border and the neutral halo; hover uses Stroke/Sub-300.
Ready to sendThe send button turns black. Empty or sendDisabled: Nuton disabled colours, not 30% opacity.
DisabledBg/Weak-50, Text/Disabled-300, no shadow.

Asking about

While a sole trader works alone, a company follows a plan agreed by its shareholders.

Context cardClamped to two lines. With onRemove it has a 24px remove button, and focus returns to the text box.

Component tokens

TokenValueUsed for
--nu-chat-panel-header-height66px (full view 60px)Header height
--nu-chat-panel-measure720pxFull-view conversation width
--nu-chat-panel-gapspace-6 (full view 28px)Space between messages
--nu-chat-bubble-max90%User bubble width
--nu-chat-action-bg / -hoverBg/Weak-50 / Bg/Soft-200Copy button fill
--nu-chat-thinking-turn2sOne turn of the sparkle
--nu-chat-composer-radius28pxComposer field corners
--nu-chat-composer-send36pxSend 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 ChatDock on 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.

Iterative design process

Good design is rarely right the first time. Teams work in loops: make something small, test it with people, learn what went wrong and improve it.

Nuri

Your learning companion

What do you need clarity on?

  • 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: ChatDock turns 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.

Chapter 2 · Business structures

While a sole trader works alone, a company follows a plan agreed by its shareholders. Each structure changes who decides, who pays the debts and how profit is shared.

My notes

Nothing saved yet.

  • 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 actions if you need them.

API reference

ChatPanel

ChatPanel props
PropTypeDefaultDescription
childrenReactNode—ChatWelcome, ChatMessage and ChatThinking, in order.
footerReactNode—Pinned under the log: usually ChatModeToggle and ChatComposer.
titleReactNodelabels.titleName in the header (“AI Tutor”). Names the panel region.
titleAsElementType'h2'Heading element for the name.
subtitleReactNodelabels.subtitleLine under the name (“Here to help”). null hides it.
avatarReactNode—28px image before the name. Decorative.
onlinebooleanfalseGreen dot on the avatar, named “Online”.
actionsReactNode—Header buttons, e.g. expand and collapse.
view'panel' | 'full''panel'Full: 60px header, conversation centred at 720px.
compactbooleanfalseNo border or radius, inside a Sheet or Modal.
loadingbooleanfalseThree Skeleton rows instead of children; the log is busy.
busybooleanfalseSets aria-busy on the log while you stream a reply.
labelsPartial<ChatPanelLabels>—title 'AI Tutor', subtitle 'Here to help', log 'Conversation', online 'Online', loading 'Loading conversation'.
…section propsHTMLAttributes—Go to the <section>. The ref points at it.

ChatWelcome

ChatWelcome props
PropTypeDefaultDescription
titlerequiredReactNode—The greeting.
titleAsElementType'h3'Heading element for the greeting.
descriptionReactNode—The line under the greeting.
markReactNode—52px mark or avatar. Decorative.
childrenReactNode—Extra content under the description.

ChatMessage

ChatMessage props
PropTypeDefaultDescription
from'user' | 'assistant''assistant'User: a bubble on the right. Assistant: prose.
childrenReactNode—The content. Assistant messages accept rich content.
quote · quoteSourceReactNode—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.
sourcesReactNode—Usually ChatSources, under the answer.
actionsReactNode—Usually ChatMessageActions.
authorstring—Sender name read before the message. Defaults to labels.user or labels.assistant.
labelsPartial<ChatMessageLabels>—user 'You', assistant 'AI Tutor', quote 'Question about highlighted lesson text', retry 'Retry'.

ChatSources

ChatSources props
PropTypeDefaultDescription
sourcesrequired{ href; title?; id? }[]—The pages. Without a title the link shows the host name. Renders nothing when empty.
labelsPartial<{ title; newTab }>—title 'Sources from the web', newTab '(opens in a new tab)'.

ChatMessageActions and ChatActionButton

ChatMessageActions props
PropTypeDefaultDescription
copyTextstring—Written to the clipboard when Copy is pressed.
onCopy() => void | Promise—Called on Copy. A rejected promise doesn’t show “Copied!”.
copiedDurationnumber2000How long the check and “Copied!” show, in ms.
showCopy · showFeedbackbooleantrue · falseCopy is on; Good and Bad response toggles are off (not on live).
feedback · defaultFeedback'good' | 'bad' | nullnullControlled or uncontrolled feedback.
onFeedbackChange(feedback) => void—Called with the new feedback; null when cleared.
appearance'lighter' | 'stroke' | 'ghost''lighter'Button appearance.
childrenReactNode—Extra ChatActionButtons after the built-in ones.
labelsPartial<ChatMessageActionsLabels>—group 'Response actions', copy 'Copy', copied 'Copied!', good 'Good response', bad 'Bad response'.
ChatActionButton props
PropTypeDefaultDescription
labelrequiredstring—Accessible name of the icon-only button; also its tooltip.
childrenrequiredReactNode—The 16px icon.
tooltipReactNode | falselabelTooltip text, or false for none. Never linked as a description.
appearance'lighter' | 'stroke' | 'ghost''lighter'Fill style.
activebooleanfalseDark “ready” look without toggle semantics.
pressedboolean—Makes it a toggle: sets aria-pressed and the dark look.
…button propsButtonHTMLAttributes—type defaults to "button". The ref points at the button.

ChatThinking

ChatThinking props
PropTypeDefaultDescription
variant'thinking' | 'research''thinking'Sparkle, or globe while the web is searched.
labelsPartial<{ thinking; research }>—'Thinking...' and 'Searching the web…'.

ChatModeToggle

ChatModeToggle props
PropTypeDefaultDescription
optionsChatModeOption[]CHAT_MODE_OPTIONS{ value, label, hint?, icon? }. Default: Grounded and Research.
value · defaultValuestringfirst optionControlled or uncontrolled value.
onValueChange(value) => void—Called when the selection changes.
disabledbooleanfalseDisables every option.
compactbooleanfalseOnly 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).
labelsPartial<{ group }>{ group: 'Answer mode' }Names the radio group, unless you pass aria-labelledby.

ChatComposer and ChatContextCard

ChatComposer props
PropTypeDefaultDescription
onSend(text: string) => void—Called with the trimmed text on Enter or the send button. Uncontrolled composers clear.
value · defaultValuestring—Controlled or uncontrolled text.
onValueChange(text: string) => void—Called on every change. onChange still fires with the event.
sendDisabledbooleanfalseDisables sending while a reply is written. Typing still works.
sendOnEnterbooleantrueEnter 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).
addFileIconReactNode—Replaces the add-file icon (a Remix icon), e.g. a plus.
sendIconReactNode—Replaces the send icon (a Remix icon), e.g. an arrow pointing right.
maxHeightnumber140The text box grows to this height in px, then scrolls.
suggestionstring—Shown as the placeholder while empty; Tab or → fills it in.
onSuggestionAccept(text: string) => void—Called when the suggestion is filled in.
topSlotReactNode—Above the field, usually a ChatContextCard.
containedbooleanfalseDraws 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).
toolsReactNode—Extra ChatActionButtons before the text. Off by default.
onAddFile · onFilesSelected() => void · (files) => void—Show the add-file button. Off by default.
accept · multiplestring · booleanmultiple: truePassed to the file picker.
attachments · onRemoveAttachmentChatComposerAttachment[] · (id) => void—Compact file cards above the field. Uploading files block sending.
invalid · disabled · requiredboolean—Inside a Field, these come from the Field.
labelsPartial<ChatComposerLabels>—input 'Message', placeholder 'Ask me anything', send 'Send message', suggestionKey 'Tab', suggestionHint (s) => …, addFile, attachments, removeAttachment.
…textarea propsTextareaHTMLAttributes—placeholder, name, maxLength and others go to the textarea. The ref points at it.
ChatStep props (Research, C106)
PropTypeDefaultDescription
labelReactNode—What the step was, e.g. “Thought Process” or “Searched the web”.
iconReactNode—A Remix icon before the label, such as a globe. Decorative.
childrenReactNode—What the step found or did. Leave it out for a plain label with nothing to open.
open / defaultOpen / onOpenChangeboolean / boolean / (open) => void—Controlled or initial open state. The row is a button with aria-expanded; the chevron turns down when open.
ChatQuestion props (Research, C106)
PropTypeDefaultDescription
questionstring—The clarifying question.
optionsstring[]—The answers, plus an automatic free-text “Something else”.
multiplebooleanfalsetrue: checkboxes and a Next button for several answers. false: pick one and it is sent.
defaultPickedstring[]—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.
ChatContextCard props
PropTypeDefaultDescription
childrenrequiredReactNode—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).
labelsPartial<{ label; remove }>—label 'Asking about', remove 'Remove highlight context'.

Accessibility

Keyboard

KeyBehaviour
TabMoves 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
EnterIn the composer: sends. On a button: activates it
Shift / EnterIn the composer: adds a line
← / → / ↑ / ↓In the answer mode: selects the previous or next mode
Home / EndIn the answer mode: selects the first or last mode
EscCloses 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

DoAdd the reply when it is complete The log announces each addition once, so screen readers hear the whole answer.
AI Tutor:A
AI Tutor:sole
AI Tutor:trader…
Don'tAdd a message per streamed word Each addition is announced separately and becomes noise. If you stream, set busy on the panel.
DoKeep the suggestion to one short question It sits in the placeholder; people read it at a glance and press Tab.
Don'tWrite a paragraph as the suggestion It is cut off in the one-line field and is hard to scan.
DoShow sources only for Research answers They tell people which answers came from outside their material.
AI Tutor:Most bakeries in the UK are sole traders.
Don'tHide where a web answer came from People can’t check a claim they can’t trace.