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.
"use client";
import { Thread } from "@intentface/ui/thread";
const LINES = [
"Thread owns the scroll container and nothing else.",
"It tracks whether you are at the bottom, follows new content while you are, and releases the moment you scroll away.",
"Viewport is the scrolling element. Content is the column inside it.",
"Neither imposes width, spacing, or colour — that is all yours.",
"Scroll this box to see the follow behaviour release.",
"Everything below is plain text for now; messages come next.",
];
// Stage 1 — just the scroll container, filled with plain rows.
// autoScroll="bottom" reserves no space for the newest row, so the rows need no Message.Turn
// wrapper yet. Stage 3 groups them into turns and uses the default `follow`.
export const ThreadStage = () => (
<div className="h-[280px] w-full max-w-xl overflow-hidden rounded-xl bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:bg-zinc-900">
<Thread.Root autoScroll="bottom" className="relative flex h-full w-full overflow-hidden">
<Thread.Viewport className="h-full w-full overflow-x-hidden overflow-y-auto outline-none [overflow-anchor:auto]">
<Thread.Content className="mx-auto flex w-full flex-col gap-4 p-4">
{LINES.map((line) => (
<p key={line} className="text-sm text-zinc-700 leading-6 dark:text-zinc-300">
{line}
</p>
))}
</Thread.Content>
</Thread.Viewport>
</Thread.Root>
</div>
);Thread.Viewport is the scrolling element and Thread.Content is the column
inside it. Neither sets width, spacing, or colour. The viewport does need a
definite height, here h-full inside a sized root, because its content cannot
size it. At this stage the rows are plain paragraphs, so the thread uses
autoScroll="bottom", which lands each new row at the bottom and needs no turns.
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.
"use client";
import { Message } from "@intentface/ui/message";
import { Thread } from "@intentface/ui/thread";
const MESSAGES = [
{ id: "1", role: "user", text: "What does Message actually render?" },
{
id: "2",
role: "assistant",
text: "A div with data-role, data-last and data-error on it, plus whatever you put inside. No bubble, no avatar, no alignment.",
},
{ id: "3", role: "user", text: "So the bubble is mine?" },
{
id: "4",
role: "assistant",
text: "Entirely. Read data-role through a group and style the two sides differently — that's the whole mechanism.",
},
];
// Stage 2 — the same thread, with each row now a Message that reports its role.
// autoScroll="bottom" reserves no space for the newest row, so the rows need no Message.Turn
// wrapper yet. Stage 3 groups them into turns and uses the default `follow`.
export const MessagesStage = () => (
<div className="h-[280px] w-full max-w-xl overflow-hidden rounded-xl bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:bg-zinc-900">
<Thread.Root autoScroll="bottom" className="relative flex h-full w-full overflow-hidden">
<Thread.Viewport className="h-full w-full overflow-x-hidden overflow-y-auto outline-none [overflow-anchor:auto]">
<Thread.Content className="mx-auto flex w-full flex-col gap-4 p-4">
{MESSAGES.map((message, index) => (
<Message.Root
key={message.id}
role={message.role}
isLast={index === MESSAGES.length - 1}
className="group flex w-full flex-col gap-1.5 data-[role=user]:items-end"
>
{/* data-role sits on Root, so the bubble reads it through the group. */}
<Message.Text className="text-sm text-zinc-700 leading-6 group-data-[role=user]:max-w-[80%] group-data-[role=user]:rounded-[20px] group-data-[role=user]:bg-white group-data-[role=user]:px-3.5 group-data-[role=user]:py-1.5 group-data-[role=user]:text-zinc-900 group-data-[role=user]:shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_rgb(0_0_0/0.04)] dark:text-zinc-300 dark:group-data-[role=user]:bg-zinc-800 dark:group-data-[role=user]:text-zinc-100 dark:group-data-[role=user]:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16)]">
{message.text}
</Message.Text>
</Message.Root>
))}
</Thread.Content>
</Thread.Viewport>
</Thread.Root>
</div>
);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
Sending from Composer.Root hands you a ComposerSubmitData and does nothing
else; appending the turn is your call.
"use client";
import { Composer, type ComposerSubmitData } from "@intentface/ui/composer";
import { Message } from "@intentface/ui/message";
import { Thread, useThread } from "@intentface/ui/thread";
import { ArrowDown, ArrowUp } from "@keyline-icons/react";
import { useState } from "react";
type ChatMessage = { id: string; role: "user" | "assistant"; text: string };
const INITIAL: ChatMessage[] = [
{ id: "1", role: "user", text: "What does Composer.Root render?" },
{
id: "2",
role: "assistant",
text: "A div. Press Enter or the send button, and onSubmit receives a ComposerSubmitData describing what was sent.",
},
{ id: "3", role: "user", text: "Does it clear itself afterwards?" },
{
id: "4",
role: "assistant",
text: "Yes, once your handler runs. Anything you want to keep — the text, the files — is already in the data you were handed.",
},
{ id: "5", role: "user", text: "Can I stop an empty message from sending?" },
{
id: "6",
role: "assistant",
text: "Check the text in onSubmit and return early, the way this demo does.",
},
{ id: "7", role: "user", text: "Where does the composer put its reply?" },
{
id: "8",
role: "assistant",
text: "Nowhere — onSubmit hands you the text and you decide. Here it just appends to local state.",
},
];
// A turn is a user message and whatever answers it, so the newest one lands
// whole — the primitive puts the reserve on the last child.
const toTurns = (messages: ChatMessage[]) =>
messages.reduce<ChatMessage[][]>((turns, message) => {
const current = turns.at(-1);
if (message.role === "user" || !current) turns.push([message]);
else current.push(message);
return turns;
}, []);
// Stage 3 — a floating composer appending to local state. Thread measures the
// composer and publishes the room it takes as --thread-inset-bottom, which Content
// pads by. Scroll up and the button above the composer brings you back to the
// latest turn.
export const ComposerStage = () => {
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL);
const handleSubmit = (data: ComposerSubmitData) => {
if (data.kind !== "message" || !data.text.trim()) return;
setMessages((current) => [
...current,
{ id: `${current.length}`, role: "user", text: data.text },
]);
};
return (
<div className="h-[400px] w-full max-w-xl overflow-hidden rounded-xl bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:bg-zinc-900">
<Thread.Root className="relative flex h-full w-full overflow-hidden [--thread-inset-top:1rem] [--thread-composer-gap:2rem]">
{/* A fade over the pt-4 below, which is the gap a landed turn leaves at the top. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 z-1 h-4 bg-linear-to-b from-white to-transparent dark:from-zinc-900"
/>
<Thread.Viewport className="h-full w-full overflow-x-hidden overflow-y-auto outline-none [overflow-anchor:auto]">
<div className="relative flex min-h-full w-full flex-col items-center">
<Thread.Content className="mx-auto flex min-h-full w-full flex-col gap-4 px-4 pt-(--thread-inset-top) pb-(--thread-inset-bottom) [&>*:last-child]:min-h-(--thread-turn-min-height)">
{toTurns(messages).map((turn) => (
<Message.Turn key={turn[0].id} className="flex flex-col gap-4">
{turn.map((message) => (
<Message.Root
key={message.id}
role={message.role}
isLast={message.id === messages.at(-1)?.id}
className="group flex w-full flex-col gap-1.5 data-[role=user]:items-end"
>
<Message.Text className="text-sm text-zinc-700 leading-6 group-data-[role=user]:max-w-[80%] group-data-[role=user]:rounded-[20px] group-data-[role=user]:bg-white group-data-[role=user]:px-3.5 group-data-[role=user]:py-1.5 group-data-[role=user]:text-zinc-900 group-data-[role=user]:shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_rgb(0_0_0/0.04)] dark:text-zinc-300 dark:group-data-[role=user]:bg-zinc-800 dark:group-data-[role=user]:text-zinc-100 dark:group-data-[role=user]:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16)]">
{message.text}
</Message.Text>
</Message.Root>
))}
</Message.Turn>
))}
</Thread.Content>
</div>
</Thread.Viewport>
<Thread.Composer className="absolute inset-x-0 bottom-0 z-2 w-full">
<div className="relative flex w-full flex-col items-center px-4 pb-4">
<ScrollButton />
<Composer.Root onSubmit={handleSubmit} className="flex w-full flex-col">
<Composer.Container className="cursor-text rounded-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_-1px_rgb(0_0_0/0.08),0_6px_16px_-6px_rgb(0_0_0/0.1)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.2),0_1px_2px_rgb(0_0_0/0.12),0_6px_16px_-6px_rgb(0_0_0/0.22)]">
<Composer.Textarea className="max-h-32 min-h-12 overflow-y-auto px-2.5 pt-2.5 text-sm text-zinc-900 dark:text-zinc-100 **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-6 [&_[data-composer-editor]:focus]:outline-none">
<Composer.Placeholder
placeholder="Send a message…"
className="text-zinc-400 leading-6 dark:text-zinc-500"
/>
</Composer.Textarea>
<Composer.Actions className="flex h-12 items-center justify-end px-2.5">
<Composer.Submit className="flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0169cc] bg-linear-to-b from-[oklch(57.2%_0.166_253.2)] to-[oklch(52.9%_0.173_255)] text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.28),0_0_0_1px_oklch(46.5%_0.146_254.8),0_1px_2px_rgb(1_105_204/0.35)] transition-opacity focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40">
<ArrowUp className="size-[15px]" />
</Composer.Submit>
</Composer.Actions>
</Composer.Container>
</Composer.Root>
</div>
</Thread.Composer>
</Thread.Root>
</div>
);
};
// useThread exposes the scroll state the viewport tracks; the button is yours.
const ScrollButton = () => {
const { isAtBottom, scrollToBottom } = useThread();
if (isAtBottom) return null;
return (
<button
type="button"
onClick={() => scrollToBottom()}
aria-label="Scroll to latest"
className="absolute -top-10 z-10 flex size-8 cursor-pointer items-center justify-center rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] text-zinc-500 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] transition-colors hover:from-[#fafafa] hover:to-[#f6f6f6] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-400 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134] dark:hover:text-zinc-100"
>
<ArrowDown className="size-[15px]" />
</button>
);
};Inside Thread.Composer, the thread measures the composer and publishes the
room it takes as --thread-inset-bottom. The rest of the layout is yours. The
slot sits at absolute inset-x-0 bottom-0, and Thread.Content pads its bottom
with pb-(--thread-inset-bottom), so messages scroll under the input but never
end behind it, at any composer height.
This stage also switches to the default autoScroll="follow", where a new turn
lands at the top of the viewport. Each question and its reply share one
Message.Turn, and the last turn takes min-height: var(--thread-turn-min-height)
from a rule on Thread.Content. Thread
explains how that height lines up with the padding.
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/ui/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.
Next steps
- Why the composer needs isolating — 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