Skip to main content

Chat

A whole chat in one component. The thread, each answer with its code, tool calls and sources, the row of actions under it and the composer at the bottom, all wired.

You
How do I show a tool call inside a message?
Assistant

Use Tool. It folds the call into a card with its status, and opens to show the input and the output.

<Tool status="complete">
  <Tool.Header name="get_projects" />
  <Tool.Content>
    <Tool.Input value={{ locale: 'en' }} />
    <Tool.Output value={projects} />
  </Tool.Content>
</Tool>
You
Show me one with real data.
Assistant

Two projects came back. The card above is the call itself.

1'use client';
2
3import { Chat, type ChatMessage } from '@/components/ui/chat';
4
5const THREAD: ChatMessage[] = [
6 {
7 id: 'u1',
8 role: 'user',
9 parts: [{ type: 'text', text: 'How do I show a tool call inside a message?' }],
10 },
11 {
12 id: 'a1',
13 role: 'assistant',
14 parts: [
15 { type: 'reasoning', text: 'The question is about rendering, not about running the tool.' },
16 {
17 type: 'text',
18 text: 'Use Tool. It folds the call into a card with its status, and opens to show the input and the output.',
19 },
20 {
21 type: 'code',
22 language: 'tsx',
23 code: `<Tool status="complete">
24 <Tool.Header name="get_projects" />
25 <Tool.Content>
26 <Tool.Input value={{ locale: 'en' }} />
27 <Tool.Output value={projects} />
28 </Tool.Content>
29</Tool>`,
30 },
31 ],
32 },
33 { id: 'u2', role: 'user', parts: [{ type: 'text', text: 'Show me one with real data.' }] },
34 {
35 id: 'a2',
36 role: 'assistant',
37 parts: [
38 {
39 type: 'tool',
40 name: 'get_projects',
41 status: 'complete',
42 input: { locale: 'en', limit: 2 },
43 output: [
44 { slug: 'nachui', title: 'NachUI' },
45 { slug: 'portfolio', title: 'ignaciofigueroa.dev' },
46 ],
47 },
48 { type: 'text', text: 'Two projects came back. The card above is the call itself.' },
49 {
50 type: 'sources',
51 items: [
52 { href: 'https://nachui.tech/docs/elements/ai/tool', title: 'Tool · NachUI' },
53 {
54 href: 'https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage',
55 title: 'Chatbot tool usage',
56 },
57 ],
58 },
59 ],
60 },
61];
62
63export function Default() {
64 return (
65 <div className="border-border h-[28rem] w-full max-w-2xl overflow-hidden rounded-xl border">
66 <Chat
67 messages={THREAD}
68 onSend={() => {}}
69 onRetry={() => {}}
70 onFeedback={() => {}}
71 suggestions={['What is NachUI?', 'Show me the composer']}
72 />
73 </div>
74 );
75}

Installation

pnpm dlx nachui add chat

Anatomy

1import { Chat } from '@/components/ui/chat';
1<Chat
2 messages={messages}
3 status={status}
4 onSend={({ text, files }) => send(text, files)}
5 onStop={stop}
6 onRetry={(id) => regenerate(id)}
7 onFeedback={(id, vote) => rate(id, vote)}
8 suggestions={['What is NachUI?', 'Show me the composer']}
9/>
Give it the messages and a status, and it draws the rest: the thread pinned to the newest message, a skeleton while the reply is on its way, a caret while it streams, and the composer that sends. Put it inside any parent with a fixed height and it fills it.

Composition

Open chat.tsx and you find the elements in the order they appear on screen. The chat adds no logic of its own beyond handing each part of a message to the element that knows how to draw it.
Chat
├── Chat.Thread Conversation
│ ├── Conversation.Empty + Chat.Suggestions Suggestion
│ ├── Chat.Message Message
│ │ ├── user Bubble, Attachments
│ │ └── assistant Response
│ │ ├── reasoning Reasoning
│ │ ├── text renderMarkdown
│ │ ├── code CodeBlock
│ │ ├── tool Tool
│ │ └── sources Sources
│ │ └── footer Actions
│ ├── Chat.Pending Response.Skeleton
│ └── Conversation.ScrollButton
└── Chat.Composer PromptInput
Pass children to Chat to lay the parts out yourself; every part reads the messages and handlers from context.
1<Chat messages={messages} onSend={send}>
2 <header className="border-b p-3">Support</header>
3 <Chat.Thread />
4 <Chat.Composer accept="image/*" />
5</Chat>

Message shape

A message is a role and a list of parts. Parts render in order, so a reply can think, answer, show code, call a tool and cite its sources, in that sequence.
1type ChatMessage = {
2 id: string;
3 role: 'user' | 'assistant';
4 parts: ChatPart[];
5};
6
7type ChatPart =
8 | { type: 'text'; text: string }
9 | { type: 'reasoning'; text: string }
10 | { type: 'code'; code: string; language?: string }
11 | { type: 'tool'; name: string; status: ToolStatus; input?: unknown; output?: unknown }
12 | { type: 'sources'; items: { href: string; title: string }[] }
13 | { type: 'file'; name: string; size?: number; url?: string; mediaType?: string };
Text parts go through renderMarkdown. The default splits paragraphs on blank lines; hand it react-markdown or your own renderer to get the rest.
1<Chat renderMarkdown={(text) => <Markdown>{text}</Markdown>} … />

Adapter

The chat does not know which SDK runs your model. toChatMessages maps the UIMessage array the Vercel AI SDK keeps into the shape above, without importing the SDK: text, reasoning and file parts pass through, tool-* parts become tool parts with their state folded into a status, and source-url parts are gathered into one sources part.
1'use client';
2
3import { useChat } from '@ai-sdk/react';
4import { Chat, toChatMessages } from '@/components/ui/chat';
5
6export function Support() {
7 const { messages, sendMessage, status, stop, regenerate } = useChat();
8
9 return (
10 <Chat
11 messages={toChatMessages(messages)}
12 status={status}
13 onSend={({ text, files }) => sendMessage({ text, files })}
14 onStop={stop}
15 onRetry={() => regenerate()}
16 />
17 );
18}
Anything else that produces messages works the same way: build the array, pass it in.

Streaming

status drives the two waiting states. submitted shows a skeleton under the last user message until the first token lands; streaming puts a caret after the last reply and turns the send button into a stop button.
Start a conversationAsk a question or pick one of the suggestions.
1'use client';
2
3import { useEffect, useRef, useState } from 'react';
4import { Chat, type ChatMessage, type ChatStatus } from '@/components/ui/chat';
5
6const REPLY =
7 'Every element in this thread is one you can install on its own. The chat only decides the order they sit in, and hands each one the part of the message it knows how to draw.';
8
9export function Streaming() {
10 const [messages, setMessages] = useState<ChatMessage[]>([]);
11 const [status, setStatus] = useState<ChatStatus>('ready');
12 const timer = useRef<ReturnType<typeof setInterval> | null>(null);
13
14 const stop = () => {
15 if (timer.current) clearInterval(timer.current);
16 timer.current = null;
17 setStatus('ready');
18 };
19
20 useEffect(() => stop, []);
21
22 const send = ({ text }: { text: string }) => {
23 const id = String(Date.now());
24 setMessages((previous) => [
25 ...previous,
26 { id: `u-${id}`, role: 'user', parts: [{ type: 'text', text }] },
27 ]);
28 setStatus('submitted');
29
30 const words = REPLY.split(' ');
31 let count = 0;
32
33 setTimeout(() => {
34 setStatus('streaming');
35 setMessages((previous) => [
36 ...previous,
37 { id: `a-${id}`, role: 'assistant', parts: [{ type: 'text', text: '' }] },
38 ]);
39 timer.current = setInterval(() => {
40 count += 1;
41 const text = words.slice(0, count).join(' ');
42 setMessages((previous) =>
43 previous.map((message) =>
44 message.id === `a-${id}` ? { ...message, parts: [{ type: 'text', text }] } : message,
45 ),
46 );
47 if (count >= words.length) stop();
48 }, 60);
49 }, 700);
50 };
51
52 return (
53 <div className="border-border h-[26rem] w-full max-w-2xl overflow-hidden rounded-xl border">
54 <Chat
55 messages={messages}
56 status={status}
57 onSend={send}
58 onStop={stop}
59 suggestions={['What makes this a hybrid?', 'Send anything to see it stream']}
60 />
61 </div>
62 );
63}

API Reference

Chat

PropTypeDefaultDescription
messagesChatMessage[]-The thread, oldest first
status'ready' | 'submitted' | 'streaming' | 'error''ready'Drives the skeleton, the caret and the stop button
onSend(message: { text: string; files?: File[] }) => void-Called when the composer submits
onStop() => void-Called by the stop button while a reply is in flight
onRetry(messageId: string) => void-Adds a retry action under each reply
onFeedback(messageId: string, vote: 'up' | 'down') => void-Adds thumbs up and down under each reply
suggestionsstring[][]Chips shown in the empty state
onSuggestion(text: string) => void-Called when a chip is picked; falls back to onSend
placeholderstring-Composer placeholder, shorthand for labels.placeholder
emptyTitlestring-Empty state title, shorthand for labels.emptyTitle
emptyDescriptionstring-Empty state text, shorthand for labels.emptyDescription
labelsPartial<ChatLabels>-Every visible string, with English defaults
renderMarkdown(text: string) => ReactNode-Renders text parts; default splits paragraphs
childrenReactNode-Replaces the default layout of thread and composer
classNamestring-Additional CSS classes

Chat.Thread

PropTypeDefaultDescription
childrenReactNode-Replaces the rendered messages inside the thread
classNamestring-Additional CSS classes

Chat.Message

PropTypeDefaultDescription
messageChatMessage-The message to draw
isLastbooleanfalseMarks the reply that streams and opens its reasoning
classNamestring-Additional CSS classes

Chat.Pending

Renders Response.Skeleton while status is submitted and the last message is from the user; nothing otherwise.
PropTypeDefaultDescription
classNamestring-Additional CSS classes

Chat.Suggestions

PropTypeDefaultDescription
classNamestring-Additional CSS classes

Chat.Composer

Accepts every PromptInput prop except onSubmit, so accept, maxFiles and maxFileSize pass straight through.
PropTypeDefaultDescription
childrenReactNode-Replaces the default textarea, tools and submit
classNamestring-Additional CSS classes

toChatMessages

SignatureDescription
toChatMessages(messages: UIMessageLike[]): ChatMessage[]Maps Vercel AI SDK messages to the chat's shape, no SDK import
Found something to improve?

Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.

Create an Issue