Build an AI chat interface with karaUI
A step-by-step tutorial that composes karaUI's agent components into a working chat screen: a reader-aware transcript, a composer with stop, a streaming fetch backend, suggestions for the empty state, message actions, response branches, a model picker and a context meter.
- AI
- Chat
- Streaming
- React
- Next.js
- shadcn
A chat screen looks simple until you build one. The transcript has to follow a streaming answer without yanking the reader back down when they scroll up. The composer needs a Stop button that actually stops the request. Answers need copy, retry, feedback and a way to compare regenerations. And none of it should care which model provider sits behind it. This tutorial puts karaUI's agent components together into a working chat screen, one piece at a time, with a plain fetch stream as the backend.
Install the pieces
karaUI ships as source through a shadcn-compatible registry. The @karaui namespace is listed in shadcn's public registry directory, so the CLI resolves it with no setup. Add the components this tutorial uses:
npx shadcn@latest add @karaui/message @karaui/message-scroller \
@karaui/prompt-input @karaui/streaming-response @karaui/prompt-suggestions \
@karaui/message-actions @karaui/branch-navigator @karaui/model-selector \
@karaui/context-meterOn an older shadcn CLI that doesn't read the directory, add the namespace to components.json once first:
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}Each item brings the files it imports, so message pulls in message-bubble and the shared motion tokens, and prompt-input brings its own Select and popover. You end up with plain files under components/agents/ that you own and can edit.
One array of messages
Everything on screen derives from one array. User messages are text. Assistant messages are text plus a status, which is exactly the shape StreamingResponse wants:
export type ChatStatus = "streaming" | "complete" | "error";
export type ChatMessage =
| { id: string; role: "user"; content: string }
| { id: string; role: "assistant"; content: string; status: ChatStatus };Keep ids stable and create them when a message is created, in the event handler, never during render. The scroller, the message rail and React reconciliation all key off them.
The transcript
MessageScroller is the viewport. It follows new output only while the reader is at the live edge; scroll up to reread something and it lets go until you come back. It also renders the transcript as a polite role="log" region, and busy sets aria-busy on it while a reply is in flight.
Inside it, MessageGroup spaces the rows and Message sets the side with from. User text goes in a MessageBubble; assistant text goes in a StreamingResponse, which marks itself busy while status is "streaming" and doesn't remount as text grows:
import { Sparkles } from "lucide-react";
import {
Message,
MessageAvatar,
MessageContent,
MessageGroup,
MessageTyping,
} from "@/components/agents/message";
import { MessageBubble, MessageBubbleContent } from "@/components/agents/message-bubble";
import { MessageScroller } from "@/components/agents/message-scroller";
import { StreamingResponse } from "@/components/agents/streaming-response";
<MessageScroller
busy={loading}
className="min-h-0 flex-1"
viewportClassName="px-4 py-5"
contentClassName="min-h-full"
>
<MessageGroup spacing="default">
{messages.map((message) =>
message.role === "user" ? (
<Message key={message.id} id={message.id} from="user" animateIn>
<MessageContent>
<MessageBubble variant="soft">
<MessageBubbleContent>{message.content}</MessageBubbleContent>
</MessageBubble>
</MessageContent>
</Message>
) : (
<Message key={message.id} id={message.id} from="assistant">
<MessageAvatar>
<Sparkles />
</MessageAvatar>
<MessageContent className="gap-1">
<StreamingResponse
status={message.status}
announce={false}
showActions={message.status === "error"}
onRetry={() => retry(message.id)}
>
{message.content || <MessageTyping />}
</StreamingResponse>
</MessageContent>
</Message>
),
)}
</MessageGroup>
</MessageScroller>A few details that matter:
announce={false}on the response. The scroller is already a live log, so letting the response announce too would make screen readers read every chunk twice.MessageTypingfills the gap between sending and the first token, so the reply row exists from the start and doesn't pop in late.animateInon user rows plays a one-time pop when a message is sent. Leave it off for assistant rows; their content grows in place.showActionsonly on error, so the built-in Retry appears for a failed reply while the richerMessageActions(below) handle the happy path.
Want a jump-to-message rail for long threads? Add navigation="rail" to the scroller. It previews and scrolls to the rendered Message rows.
The composer
PromptInput is an auto-growing textarea with a send button. Enter sends, Shift+Enter adds a line, and IME composition is respected. While loading is true the send button becomes a stop button that calls onStop, so those two props are the whole contract with your request lifecycle:
import { PromptInput } from "@/components/agents/prompt-input";
<div className="flex h-dvh flex-col">
{/* MessageScroller from the previous step */}
<div className="shrink-0 p-3">
<PromptInput
minRows={1}
maxRows={6}
loading={loading}
onSubmit={(prompt) => send(prompt)}
onStop={stop}
placeholder="Ask anything…"
/>
</div>
</div>Uncontrolled, it clears itself after onSubmit. Give the flex column a definite height (h-dvh here) and the scroller min-h-0 flex-1, or the transcript will push the composer off screen instead of scrolling.
Wire up streaming
The components only need two things from the network: text deltas appended to the last assistant message, and a status change when the stream ends. Here is the smallest backend that gives you both.
The server route
A route handler that turns whatever your provider SDK yields into a plain-text ReadableStream. Using pull rather than pushing in a loop means the stream only reads upstream as fast as the client consumes, and cancel runs when the browser aborts, so Stop really stops the model:
// app/api/chat/route.ts
export async function POST(request: Request) {
const { messages, model } = await request.json();
// Any provider SDK that yields text deltas as an AsyncIterable<string>.
const deltas = await streamFromYourProvider({ messages, model });
const iterator = deltas[Symbol.asyncIterator]();
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
const { value, done } = await iterator.next();
if (done) controller.close();
else controller.enqueue(encoder.encode(value));
},
// The browser aborted (Stop button, navigation): stop the upstream call too.
async cancel() {
await iterator.return?.();
},
});
return new Response(body, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}streamFromYourProvider stands in for your provider call. Most SDKs expose an async iterable of text deltas, or an event stream you can map to one.
Reading the stream
On the client, fetch takes an AbortSignal, and the body is read chunk by chunk:
export async function streamReply(
history: { role: "user" | "assistant"; content: string }[],
model: string,
signal: AbortSignal,
onDelta: (text: string) => void,
) {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history, model }),
signal,
});
if (!response.ok || !response.body) {
throw new Error("Chat request failed: " + response.status);
}
// TextDecoderStream keeps multi-byte characters intact across chunk edges.
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read();
if (done) return;
onDelta(value);
}
}Then a small hook owns the array. It appends an empty streaming reply right away, patches deltas into it, and settles the status at the end:
"use client";
import { useRef, useState } from "react";
import type { ChatMessage } from "./types";
import { streamReply } from "./stream-reply";
export function useChatState(model: string) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const controller = useRef<AbortController | null>(null);
const patch = (id: string, update: (content: string) => Partial<ChatMessage>) =>
setMessages((list) =>
list.map((m) => (m.id === id ? ({ ...m, ...update(m.content) } as ChatMessage) : m)),
);
const run = async (history: ChatMessage[], replyId: string) => {
const abort = new AbortController();
controller.current = abort;
setLoading(true);
try {
await streamReply(
history.map(({ role, content }) => ({ role, content })),
model,
abort.signal,
(delta) => patch(replyId, (content) => ({ content: content + delta })),
);
patch(replyId, () => ({ status: "complete" }));
} catch {
// A user-initiated stop keeps the partial answer; anything else is an error.
patch(replyId, () => ({ status: abort.signal.aborted ? "complete" : "error" }));
} finally {
controller.current = null;
setLoading(false);
}
};
const send = (prompt: string) => {
const user: ChatMessage = { id: crypto.randomUUID(), role: "user", content: prompt };
const reply: ChatMessage = {
id: crypto.randomUUID(),
role: "assistant",
content: "",
status: "streaming",
};
const history = [...messages, user];
setMessages([...history, reply]);
void run(history, reply.id);
};
const retry = (replyId: string) => {
const at = messages.findIndex((m) => m.id === replyId);
if (at < 0) return;
setMessages((list) =>
list.map((m) =>
m.id === replyId ? { ...m, content: "", status: "streaming" as const } : m,
),
);
void run(messages.slice(0, at), replyId);
};
const stop = () => controller.current?.abort();
return { messages, loading, send, retry, stop };
}stop aborts the fetch, the reader throws, and because the signal was aborted the partial answer is kept as "complete" rather than flagged as an error. Pass loading, send and stop to PromptInput, and retry to StreamingResponse's onRetry.
Using a chat hook instead
If you already use a chat hook such as the Vercel AI SDK's useChat, skip the hand-rolled state. The components are driven by props, so the mapping is direct: the hook's message list feeds the transcript, its send function goes to onSubmit, its stop function to onStop, and its request status becomes loading (submitted or streaming) plus each reply's status (streaming for the last message while the request runs, error when it failed, complete otherwise). Check your version's docs for how it shapes message content; recent versions split it into typed parts, and you would render the text parts inside StreamingResponse.
The empty state
A blank transcript with a blinking cursor is a poor first screen. PromptSuggestions fills it with starting points and, given the composer's text as query, filters them as the user types, highlighting the matching words. Picking one hands its prompt to onSelect. For that, the composer has to be controlled:
import { PromptSuggestions } from "@/components/agents/prompt-suggestions";
const [draft, setDraft] = useState("");
{messages.length === 0 ? (
<PromptSuggestions
suggestions={SUGGESTIONS}
query={draft}
limit={4}
onSelect={(prompt) => setDraft(prompt)}
/>
) : (
<MessageScroller>{/* … */}</MessageScroller>
)}
<PromptInput
value={draft}
onValueChange={setDraft}
loading={loading}
onSubmit={(prompt) => {
send(prompt);
setDraft(""); // controlled: clearing is your job
}}
onStop={stop}
/>What are we working on?
Pick a suggestion or type to filter them.
The cards reveal with a short stagger on first render only, arrow keys move between them, and the match count is announced politely. Use variant="chip" for a compact row of pills above the composer once a conversation has started.
Actions and branches
Once a reply finishes, put MessageActions under it: copy (with copyText), regenerate, thumbs up and down, and an optional share button and overflow menu. Thumbs-down opens a small reason form and reports through onFeedbackSubmit. If onRegenerate returns a promise, its icon spins until it settles.
Regenerating is where most chat UIs lose information: the old answer is just replaced. Keep it instead. Store an array of versions on each assistant message, append to it on regenerate, and let Branches show one at a time. BranchNavigator reads the same context and renders the ‹ 2 / 3 › control:
import { BranchContent, BranchNavigator, Branches } from "@/components/agents/branch-navigator";
import { MessageFooter } from "@/components/agents/message";
import { MessageActions } from "@/components/agents/message-actions";
// Assistant messages now keep every version and which one is showing:
// { id, role: "assistant", versions: string[], active: number, status }
<MessageContent className="gap-1">
<Branches
count={message.versions.length}
index={message.active}
onIndexChange={(active) => select(message.id, active)}
>
<BranchContent>
{message.versions.map((text, version) => (
<StreamingResponse
key={version}
status={version === message.versions.length - 1 ? message.status : "complete"}
showActions={false}
announce={false}
>
{text || <MessageTyping />}
</StreamingResponse>
))}
</BranchContent>
{message.status !== "streaming" ? (
<MessageFooter className="-ml-1.5 gap-1 px-0">
<BranchNavigator label="response" hideWhenSingle />
<MessageActions
visibility={isLast ? "always" : "hover"}
copyText={message.versions[message.active]}
onRegenerate={() => regenerate(message.id)}
onFeedbackSubmit={(details) => sendFeedback(message.id, details)}
/>
</MessageFooter>
) : null}
</Branches>
</MessageContent>BranchContent slides the incoming version in from the side you navigated toward, and the navigator announces “Response 2 of 3”. visibility="hover" keeps older replies quiet: their actions appear on hover or focus, and always on devices without hover.
Model and context
PromptInput has a simple built-in model menu (its models prop) and a leadingAction slot for anything richer. Put a ModelSelector there for a searchable picker with providers, capability badges, context window and cost, and a ContextMeter beside it so people can see how full the window is before the model starts forgetting:
import { type ContextSegment, ContextMeter } from "@/components/agents/context-meter";
import { type ModelOption, ModelSelector } from "@/components/agents/model-selector";
const MODELS: ModelOption[] = [
{
value: "nw-swift",
name: "Swift 3",
provider: "northwind",
description: "Balanced everyday model",
capabilities: ["vision", "tools", "fast"],
contextWindow: 200_000,
cost: 2,
},
// …
];
const [model, setModel] = useState("nw-swift");
const limit = MODELS.find((m) => m.value === model)?.contextWindow ?? 200_000;
const segments: ContextSegment[] = [
{ id: "system", label: "System prompt", tokens: usage.system },
{ id: "conversation", label: "Conversation", tokens: usage.conversation },
{ id: "files", label: "Files", tokens: usage.files },
];
<PromptInput
/* …as before */
leadingAction={
<>
<ModelSelector
models={MODELS}
value={model}
onValueChange={setModel}
side="top"
disabled={loading}
/>
<ContextMeter segments={segments} limit={limit} side="top" onCompact={compact} />
</>
}
/>Take the meter's numbers from the token usage your provider reports with each response, not from a character count. The meter changes color at warnAt (80% by default) and criticalAt (95%), and shows a Compact conversation button when you pass onCompact, typically a request that summarizes older turns into one message. Send the selected model with each request so the route can pick it.
Tool calls (optional)
If your agent calls tools, two more components slot into the assistant row above the response. AgentActivity streams searches, file reads and steps while the run is working, then collapses into a one-line summary. ToolApproval asks before anything risky runs and reports back through onApprove, onAlwaysAllow and onDeny; you drive its status as the call progresses.
import { AgentActivity } from "@/components/agents/agent-activity";
import { ToolApproval, ToolApprovalCode } from "@/components/agents/tool-approval";
<AgentActivity
status={run.done ? "complete" : "working"}
items={[
{ id: "s1", type: "search", query: "bun test watch mode flags" },
{ id: "t1", type: "tool", action: "read", target: "package.json" },
]}
/>
<ToolApproval
tool="terminal.run"
title="Allow this tool to run?"
description="The agent wants to run the test suite in this workspace."
status={approval.status} // "pending" → "approving" → "approved" → "running" → "complete"
parameters={[
{
id: "command",
label: "Command",
value: <ToolApprovalCode code="bun test" language="bash" />,
},
]}
onApprove={() => respond(approval.id, "allow")}
onAlwaysAllow={() => respond(approval.id, "always")}
onDeny={() => respond(approval.id, "deny")}
/>For a complete reference that also includes navigation, plans, diffs and generated media, see the Chat App usage example.
Checklist
- Add the
@karauiregistry tocomponents.jsononce, then install by name. - One message array; ids created in event handlers, never during render.
MessageScrollerwithbusy, inside a column of definite height;announce={false}on eachStreamingResponse.- Wire
loadingandonStopto anAbortController, and cancel upstream in the route. - Suggestions for the empty state, with the composer controlled.
- Keep regenerations as branches instead of overwriting them.
- Real token usage in
ContextMeter; the selected model in every request.
Related components
- Message ScrollerA scroll container for chat transcripts that stays pinned to the newest streamed text only while the reader sits near the bottom, stops following once they scroll up, and can add a rail for jumping between messages.
- MessageBuilding blocks for one chat transcript row: Message, MessageGroup, MessageAvatar, header, content and footer slots, a live marker and a typing indicator. A freshly sent row can pop up once from its trailing corner.
- Message BubbleThe visible surface of a single chat message: six tones from solid to danger, start or end alignment inherited from its Message row, tight bubble groups, a line-clamped show-more toggle, and room for links or buttons inside.
- Prompt InputThe text box where people talk to your agent: it grows with the draft up to a row limit, sends on Enter with Shift+Enter for a new line, offers a model picker and a prompt action menu, and swaps send for stop during a reply.
- Streaming ResponseWraps an assistant answer during and after streaming: content renders as it arrives, then copy, retry and thumbs up or down appear, along with a collapsible footer listing the sources behind the answer.
- Prompt SuggestionsSuggested prompt cards or chips (`PromptSuggestions`) that stagger in on first reveal, filter and highlight as the composer text changes via `query`, reflow with spring layout, move with arrow keys and hand the chosen prompt to `onSelect` — built for empty-chat welcome screens above a PromptInput.
- Message ActionsAssistant message action row (`MessageActions`) with copy that morphs into a drawn check, regenerate that spins (continuously while its promise runs), exclusive thumbs up/down that pop in filled, a thumbs-down reason popover with toggle chips and comment, share and a more menu; reveals on message hover or focus and stays visible on touch or with `visibility="always"` for the last message — a standalone, richer counterpart to StreamingResponse's built-in actions.
- Branch NavigatorVersion switcher for regenerated or edited messages: `Branches` shares a controlled or uncontrolled index, `BranchContent` cross-fades and slides the active version in from the direction you navigated, and `BranchNavigator` renders a "‹ 2 / 3 ›" control with a rolling counter, arrow/Home/End keys and live announcements, usable standalone too.
- Model SelectorSearchable AI model picker (`ModelSelector`) that morphs open from its trigger with models grouped by provider, generic geometric provider glyphs, vision/tools/reasoning/fast capability badges, context size, relative cost dots, a New tag and a gliding active row with full keyboard navigation; a richer standalone alternative to PromptInput's compact model select, controlled or uncontrolled.
- Context MeterToken usage indicator (`ContextMeter`) whose compact ring and "42k / 200k" readout expand on hover or click into a breakdown bar of system, conversation, files and tool segments in chart colors, turning warning then critical near the limit and offering an async "Compact conversation" action; drops into PromptInput's `leadingAction` slot.
- Tool ApprovalAsks a person before an agent runs a tool: it lists the call's parameters, can show the code or command to be executed, lets them approve a single run, grant standing access or deny, then follows the call through running, complete or error.
- Agent ActivityOne feed for everything an agent does while it works: free text, reasoning steps, web searches with results, read, edit and run tool calls, or compact trace entries, shown alone or interleaved in order, then folded into a one-line summary.