Conversation
Production-ready chatbot UI built on the AI SDK v6 + ai-elements, with Streamdown markdown, reasoning, tool calls, and a shimmer "Thinking..." state
Working Chatbot
Uses the AI SDK v6 (ai@^6, @ai-sdk/react@^3) with ai-elements for
the conversation shell + Streamdown for streaming markdown. Tool calls
render as collapsible cards, reasoning appears in a "Thinking..." panel that
auto-closes when done, and a shimmer indicates inflight requests.
Installation
Install the SDK + provider packages
npm install ai @ai-sdk/react @ai-sdk/groq streamdown sonner zodpnpm add ai @ai-sdk/react @ai-sdk/groq streamdown sonner zodyarn add ai @ai-sdk/react @ai-sdk/groq streamdown sonner zodbun add ai @ai-sdk/react @ai-sdk/groq streamdown sonner zodInstall the ai-elements components
Conversation pulls in everything you need: message bubbles, response (Streamdown), reasoning panel, tool-call cards, and the shimmer indicator.
npx ai-elements@latest add conversation message response reasoning tool shimmer prompt-inputCreate the API route
Make an app/api/chat/route.ts (App Router):
import { groq } from '@ai-sdk/groq';
import {
convertToModelMessages,
smoothStream,
stepCountIs,
streamText,
type UIMessage,
} from 'ai';
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: groq('openai/gpt-oss-120b'), // good function-calling on Groq
messages: await convertToModelMessages(messages),
stopWhen: stepCountIs(6),
maxOutputTokens: 8192,
experimental_transform: smoothStream({ chunking: 'word' }),
});
return result.toUIMessageStreamResponse({
sendReasoning: true, // enables <Reasoning /> rendering on the client
});
}v6 changes from v5:
convertToModelMessagesis async — alwaysawaitit.maxSteps→stopWhen: stepCountIs(n)maxTokens→maxOutputTokenstoDataStreamResponse()→toUIMessageStreamResponse()- Tool definitions use
inputSchema(notparameters) and thetool({...})helper.
Add your API key
GROQ_API_KEY=your-groq-api-keyHook it up on the client
useChat in v6 no longer manages input state — own it with useState and
submit via sendMessage({ parts: [{ type: 'text', text }] }):
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
import { isToolUIPart, getToolName } from 'ai';
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import { Message, MessageContent } from '@/components/ai-elements/message';
import { Response } from '@/components/ai-elements/response';
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning';
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool';
import { Shimmer } from '@/components/ai-elements/shimmer';
import {
PromptInput,
PromptInputSubmit,
PromptInputTextarea,
} from '@/components/ai-elements/prompt-input';
export default function WorkingChatbot() {
const [input, setInput] = useState('');
const { messages, status, sendMessage } = useChat();
const isLoading = status === 'submitted' || status === 'streaming';
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage({ parts: [{ type: 'text', text: input.trim() }] });
setInput('');
};
return (
<div className="relative size-full h-[600px] rounded-lg border">
<Conversation>
<ConversationContent>
{messages.length === 0 ? (
<ConversationEmptyState
title="Start a conversation"
description="Ask me anything below"
/>
) : (
messages.map((message, i) => {
const isLast = i === messages.length - 1;
const reasoning = message.parts
.filter((p) => p.type === 'reasoning')
.map((p) => (p as { text: string }).text)
.join('\n\n');
const lastPart = message.parts.at(-1);
const reasoningStreaming =
isLast &&
status === 'streaming' &&
lastPart?.type === 'reasoning';
return (
<Message from={message.role} key={message.id}>
<MessageContent>
{reasoning && (
<Reasoning isStreaming={reasoningStreaming}>
<ReasoningTrigger />
<ReasoningContent>{reasoning}</ReasoningContent>
</Reasoning>
)}
{message.parts.map((part, idx) => {
if (part.type === 'text') {
return (
<Response key={`${message.id}-${idx}`}>
{part.text}
</Response>
);
}
if (isToolUIPart(part)) {
return (
<Tool key={part.toolCallId} defaultOpen>
<ToolHeader
type={`tool-${getToolName(part)}` as never}
state={part.state}
/>
<ToolContent>
{(part.state === 'input-available' ||
part.state === 'output-available' ||
part.state === 'output-error') &&
part.input !== undefined && (
<ToolInput input={part.input} />
)}
{part.state === 'output-available' && (
<ToolOutput
output={
<pre className="text-xs whitespace-pre-wrap p-3">
{typeof part.output === 'string'
? part.output
: JSON.stringify(part.output, null, 2)}
</pre>
}
errorText={undefined}
/>
)}
{part.state === 'output-error' && (
<ToolOutput
output={null}
errorText={part.errorText}
/>
)}
</ToolContent>
</Tool>
);
}
return null;
})}
</MessageContent>
</Message>
);
})
)}
{isLoading &&
messages.at(-1)?.role === 'user' && (
<Message from="assistant">
<MessageContent>
<Shimmer duration={1.8}>
{status === 'submitted' ? 'Thinking...' : 'Generating...'}
</Shimmer>
</MessageContent>
</Message>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput onSubmit={handleSubmit} className="mt-4">
<PromptInputTextarea
value={input}
placeholder="Say something..."
onChange={(e) => setInput(e.currentTarget.value)}
/>
<PromptInputSubmit
status={status === 'streaming' ? 'streaming' : 'ready'}
disabled={!input.trim()}
/>
</PromptInput>
</div>
);
}Point the demo at your route
The bundled working-chatbot defaults to /api/demo-chat. Switch it to your route
by passing a custom transport:
+ import { DefaultChatTransport } from 'ai';
const { messages, status, sendMessage } = useChat({
+ transport: new DefaultChatTransport({ api: '/api/chat' }),
});What each piece does
| Piece | Why it's here |
|---|---|
Conversation / ConversationContent | Sticky-bottom scroll container + content slot. Auto-scrolls as new tokens stream in. |
ConversationEmptyState | Renders icon/title/description placeholder when messages.length === 0. |
ConversationScrollButton | Floating "scroll to bottom" pill that appears when the user scrolls up. |
ConversationDownload | Optional. Floating button that exports the chat as a .md file via the bundled messagesToMarkdown helper. |
Message / MessageContent | Per-bubble role-aware styling (is-user / is-assistant). |
Response | The Streamdown-powered markdown renderer. Handles incomplete code fences, GFM tables, math, and live re-flow during streaming. |
Reasoning / ReasoningTrigger / ReasoningContent | Collapsible "Thinking..." panel that auto-opens while the model streams reasoning parts and auto-closes 1s after they finish. |
Tool / ToolHeader / ToolInput / ToolOutput | Collapsible tool-call card showing input JSON + output + status badge (input-streaming / input-available / output-available / output-error). |
Shimmer | Animated gradient text. Use for the "Thinking..." / "Generating..." indicator in the assistant bubble between the user's send and the first streamed token. |
PromptInput family | Textarea + submit button wired for submitted/streaming status. |
Conversation Example
Last updated on
Chatbot UI
A great chatbot UI design can make a huge difference in user engagement and satisfaction. Here are some of the best chatbot UI designs to inspire you.
Admin DashboardNew
A comprehensive admin dashboard with real-time analytics, user management, and system monitoring. Features custom components with minimal shadcn usage.
