Build a chat
Four stages from an empty page to a streaming chat, one primitive at a time.
Quick start gets a chat on screen in one go. This page takes the same result apart and rebuilds it in four stages, so you can see what each primitive contributes and where your own code belongs.
The first three stages run on static data and local state — copy any of them and they work immediately. The fourth connects a real model.
1. Render a thread
Thread is a scroll container with opinions about one thing: staying at the
bottom. It follows new content while you're already there and releases the moment
you scroll away, so a stream never yanks the page out from under you.
Thread.Viewport is the scrolling element and Thread.Content is the column
inside it. Neither sets width, spacing, or colour. At this stage the rows are
plain paragraphs — nothing here knows what a message is.
2. Add messages
Message.Root renders a div carrying data-role, data-last, and
data-error, and nothing else. No bubble, no avatar, no alignment: the role is
reported, and you decide what it looks like.
The bubble is styled by reading data-role through a Tailwind group on the root,
which is why the same Message.Text element renders as a card for the user and
as bare text for the assistant. role is an opaque string — the package never
enumerates the set, so "system" or "tool" work the same way.
3. Wire the composer
Composer.Root is a form. Submitting hands you a ComposerSubmitData and does
nothing else; appending the turn is your call.
Two things happen automatically once the composer is docked inside
Thread.Composer. The thread measures it and publishes the height as
--thread-overlay-bottom-height, which the content column uses as bottom
padding — so messages never hide behind the input, at any composer height. And
the newest turn gets --thread-turn-min-height so it lands at the top of the
viewport rather than jumping.
At this point everything is local: submitting appends a message, and no request leaves the page.
4. Stream responses
This stage has no live demo. Docs pages here never call a model, so what follows is the wiring, shown as code rather than run.
Swap local state for useChat, which owns the message list and the request:
"use client";
import { useChat } from "@ai-sdk/react";
import { Composer, type ComposerSubmitData } from "@intentface/chat/composer";
export function Chat() {
const { messages, sendMessage, status, stop } = useChat();
const handleSubmit = (data: ComposerSubmitData) => {
if (data.kind === "message" && data.text.trim()) {
sendMessage({ text: data.text, files: data.files });
}
};
return (
<Composer.Root onSubmit={handleSubmit}>
{/* … the same tree as stage 3 … */}
<Composer.Submit isGenerating={status === "streaming"} onStop={stop} />
</Composer.Root>
);
}Composer.Submit takes isGenerating and onStop: while generating it flips to
type="button", relabels itself, and calls onStop instead of submitting, so
send-to-stop needs no branching of your own.
The route is the standard AI SDK handler — nothing in this package is involved:
// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { convertToModelMessages, streamText, type UIMessage } from "ai";
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai("gpt-5"),
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}messages from useChat are UIMessages, which satisfy this package's
ChatMessage contract structurally — so they render through Message with no
adapter — the package's ChatMessage is satisfied structurally.
Next steps
- Composer performance — keeping the composer from re-rendering on every chunk
- Composer — commands, chips, attachments, and the ask-user flow
- Steps and Reasoning — surfacing tool calls and thinking