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.

kara9 min read
  • 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:

Shellterminal
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-meter

On an older shadcn CLI that doesn't read the directory, add the namespace to components.json once first:

JSONcomponents.json
{
  "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:

TStypes.ts
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:

TSXtranscript.tsx
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.
  • MessageTyping fills the gap between sending and the first token, so the reply row exists from the start and doesn't pop in late.
  • animateIn on user rows plays a one-time pop when a message is sent. Leave it off for assistant rows; their content grows in place.
  • showActions only on error, so the built-in Retry appears for a failed reply while the richer MessageActions (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:

TSXchat.tsx
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:

TSapp/api/chat/route.ts
// 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:

TSstream-reply.ts
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:

TSXuse-chat-state.ts
"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:

TSXchat.tsx
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.

Live PromptSuggestions over a controlled PromptInput. Type “email” to filter, or pick a card to fill the composer.

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:

TSXassistant-message.tsx
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>
Write a haiku about deploys.
A haiku about deploys: Logs scroll like spring rain, canary sings in the mine — all regions are green.
ResponsesResponse 3 of 3
BranchNavigator and MessageActions under one reply. Regenerate to add a version, then page back through earlier ones.

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:

TSXcomposer.tsx
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.

TSXassistant-message.tsx
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 @karaui registry to components.json once, then install by name.
  • One message array; ids created in event handlers, never during render.
  • MessageScroller with busy, inside a column of definite height; announce={false} on each StreamingResponse.
  • Wire loading and onStop to an AbortController, 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.

All posts