Skip to main content
NachUI

Conversation

The scrolling thread of a chat, pinned to the latest message while it streams and easy to get back to once you scroll away.

Which component do I use for an agent run?
Task. One per step, each with a status, and Task.File for the files it touched.
And for the model thinking out loud?
Reasoning. It opens while the stream runs and folds away once the answer starts.
How do I show the files a user attached?
Attachments, as a grid, a row of chips or a list. The composer already uses it.
Can I keep the thread pinned to the latest message?
That is this component. Scroll up and a button appears to bring you back down.
1'use client';
2
3import { Conversation } from '@/components/ui/conversation';
4import { Bubble } from '@/components/ui/bubble';
5import { Message } from '@/components/ui/message';
6
7const THREAD = [
8 { role: 'user', text: 'Which component do I use for an agent run?' },
9 {
10 role: 'assistant',
11 text: 'Task. One per step, each with a status, and Task.File for the files it touched.',
12 },
13 { role: 'user', text: 'And for the model thinking out loud?' },
14 {
15 role: 'assistant',
16 text: 'Reasoning. It opens while the stream runs and folds away once the answer starts.',
17 },
18 { role: 'user', text: 'How do I show the files a user attached?' },
19 {
20 role: 'assistant',
21 text: 'Attachments, as a grid, a row of chips or a list. The composer already uses it.',
22 },
23 { role: 'user', text: 'Can I keep the thread pinned to the latest message?' },
24 {
25 role: 'assistant',
26 text: 'That is this component. Scroll up and a button appears to bring you back down.',
27 },
28] as const;
29
30export function Default() {
31 return (
32 <div className="border-border h-72 w-full max-w-md overflow-hidden rounded-xl border">
33 <Conversation className="h-full">
34 <Conversation.Content>
35 {THREAD.map((message, index) => (
36 <Message key={index} align={message.role === 'user' ? 'end' : 'start'}>
37 <Message.Content>
38 <Bubble variant={message.role === 'user' ? 'default' : 'muted'}>
39 <Bubble.Content>{message.text}</Bubble.Content>
40 </Bubble>
41 </Message.Content>
42 </Message>
43 ))}
44 </Conversation.Content>
45 <Conversation.ScrollButton />
46 </Conversation>
47 </div>
48 );
49}

Installation

pnpm dlx nachui add conversation

Anatomy

1import { Conversation } from '@/components/ui/conversation';
1<Conversation className="h-96">
2 <Conversation.Content>
3 {messages.map((message) => (
4 <Message key={message.id}>...</Message>
5 ))}
6 </Conversation.Content>
7 <Conversation.ScrollButton />
8</Conversation>
The root is the scroll container, so give it a height. It renders as a polite live region, which is how a screen reader hears new messages arrive without being interrupted mid sentence.

Composition

Use the following composition to build a Conversation:
Conversation
├── Conversation.Content
├── Conversation.ScrollButton
└── Conversation.Empty

Sticking to the bottom

While the user is at the bottom, new content keeps the thread pinned there, which is what you want during a stream. The moment they scroll up the thread lets go, and the scroll button shows up so the latest message is one click away. Scrolling back down by hand sticks it again.
Nothing yetStart the stream and scroll up to see the thread let go.
1'use client';
2
3import { useEffect, useState } from 'react';
4import { Conversation } from '@/components/ui/conversation';
5import { Bubble } from '@/components/ui/bubble';
6import { Message } from '@/components/ui/message';
7
8const LINES = [
9 'Reading the registry.',
10 'Found 58 components across three families.',
11 'Matching the request to prompt-input.tsx.',
12 'Writing the demo.',
13 'Regenerating the registry.',
14 'Running the type check.',
15 'Done. Two files changed.',
16];
17
18export function Streaming() {
19 const [running, setRunning] = useState(false);
20 const [messages, setMessages] = useState<string[]>([]);
21
22 useEffect(() => {
23 if (!running) return;
24 const interval = window.setInterval(() => {
25 setMessages((previous) => {
26 const next = LINES[previous.length % LINES.length];
27 return next ? [...previous, next] : previous;
28 });
29 }, 900);
30 return () => window.clearInterval(interval);
31 }, [running]);
32
33 return (
34 <div className="flex w-full max-w-md flex-col gap-4">
35 <div className="flex items-center gap-2">
36 <button
37 type="button"
38 onClick={() => setRunning((previous) => !previous)}
39 className="border-border hover:bg-muted w-fit rounded-full border px-3 py-1.5 text-xs transition-colors"
40 >
41 {running ? 'Stop stream' : 'Start stream'}
42 </button>
43 <button
44 type="button"
45 onClick={() => setMessages([])}
46 className="text-muted-foreground hover:text-foreground px-2 py-1.5 text-xs transition-colors"
47 >
48 Clear
49 </button>
50 </div>
51 <div className="border-border h-64 w-full overflow-hidden rounded-xl border">
52 <Conversation className="h-full">
53 {messages.length === 0 ? (
54 <Conversation.Empty>
55 <span className="text-foreground">Nothing yet</span>
56 <span>Start the stream and scroll up to see the thread let go.</span>
57 </Conversation.Empty>
58 ) : (
59 <Conversation.Content>
60 {messages.map((text, index) => (
61 <Message key={index}>
62 <Message.Content>
63 <Bubble variant="muted">
64 <Bubble.Content>{text}</Bubble.Content>
65 </Bubble>
66 </Message.Content>
67 </Message>
68 ))}
69 </Conversation.Content>
70 )}
71 <Conversation.ScrollButton />
72 </Conversation>
73 </div>
74 </div>
75 );
76}
Set stickToBottom={false} to turn the pinning off and keep only the scroll container and the button.

Empty state

Conversation.Empty fills the thread before the first message. Put the intro or a few suggestions in there, and swap it for Conversation.Content once there is something to show.

useConversation

useConversation() reads the stuck state and exposes scrollToBottom, so a composer can jump the thread down after sending.
1const { stuck, scrollToBottom } = useConversation();

API Reference

Conversation

PropTypeDefaultDescription
stickToBottombooleantrueKeep the thread pinned while content grows
onStickChange(stuck: boolean) => void-Called when the thread sticks or lets go
classNamestring-Additional CSS classes, give it a height

Conversation.Content

PropTypeDefaultDescription
classNamestring-Additional CSS classes

Conversation.ScrollButton

PropTypeDefaultDescription
labelstring'Scroll to bottom'Accessible name of the button
childrenReactNode-Replaces the chevron icon
classNamestring-Additional CSS classes

Conversation.Empty

PropTypeDefaultDescription
classNamestring-Additional CSS classes
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