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

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.

What does Message actually render?
A div with data-role, data-last and data-error on it, plus whatever you put inside. No bubble, no avatar, no alignment.
So the bubble is mine?
Entirely. Read data-role through a group and style the two sides differently — that's the whole mechanism.

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.

Where does the composer put its reply?
Nowhere — onSubmit hands you the text and you decide. Here it just appends to local state.

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