---
title: Build a chat
description: Four stages from an empty page to a streaming chat, one primitive at a time.
---

[Quick start](/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.

```tsx title="build-a-chat/demos/thread.tsx"
"use client";

import { Thread } from "@intentface/chat/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.
export const ThreadStage = () => (
  <div className="h-[280px] w-full max-w-xl overflow-hidden rounded-xl border border-[#f0f0f0] bg-white dark:border-[#262626] dark:bg-[#111111]">
    <Thread.Root 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 leading-[1.7] text-[#1a1a1a] dark:text-[#fcfcfc]">
              {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. 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.

```tsx title="build-a-chat/demos/messages.tsx"
"use client";

import { Message } from "@intentface/chat/message";
import { Thread } from "@intentface/chat/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.
export const MessagesStage = () => (
  <div className="h-[280px] w-full max-w-xl overflow-hidden rounded-xl border border-[#f0f0f0] bg-white dark:border-[#262626] dark:bg-[#111111]">
    <Thread.Root 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 data-[role=user]:items-end"
            >
              {/* data-role sits on Root, so the bubble reads it through the group. */}
              <Message.Text className="text-sm leading-[1.7] text-[#1a1a1a] group-data-[role=user]:min-h-9 group-data-[role=user]:max-w-[80%] group-data-[role=user]:rounded-2xl group-data-[role=user]:border group-data-[role=user]:border-[#f0f0f0] group-data-[role=user]:bg-white group-data-[role=user]:px-3 group-data-[role=user]:py-1.5 group-data-[role=user]:shadow-xs dark:text-[#fcfcfc] dark:group-data-[role=user]:border-[#262626] dark:group-data-[role=user]:bg-[#181818]">
                {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

`Composer.Root` is a `form`. Submitting hands you a `ComposerSubmitData` and does
nothing else; appending the turn is your call.

```tsx title="build-a-chat/demos/composer.tsx"
"use client";

import { Composer, type ComposerSubmitData } from "@intentface/chat/composer";
import { Message } from "@intentface/chat/message";
import { Thread } from "@intentface/chat/thread";
import { type ComponentProps, useState } from "react";

type ChatMessage = { id: string; role: "user" | "assistant"; text: string };

const INITIAL: ChatMessage[] = [
  { id: "1", role: "user", text: "Where does the composer put its reply?" },
  {
    id: "2",
    role: "assistant",
    text: "Nowhere — onSubmit hands you the text and you decide. Here it just appends to local state.",
  },
];

// Stage 3 — a docked composer appending to local state. Thread measures the
// dock and publishes the reserve as --thread-overlay-bottom-height.
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-[360px] w-full max-w-xl overflow-hidden rounded-xl border border-[#f0f0f0] bg-white dark:border-[#262626] dark:bg-[#111111]">
      <Thread.Root className="relative flex h-full w-full overflow-hidden [--thread-overlay-top-height:1rem]">
        <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 pt-(--thread-overlay-top-height) pb-(--thread-overlay-bottom-height)">
            <Thread.Content className="mx-auto flex min-h-full w-full flex-col gap-4 px-4 [&>*:last-child]:min-h-(--thread-turn-min-height,0px)">
              {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 data-[role=user]:items-end"
                >
                  <Message.Text className="text-sm leading-[1.7] text-[#1a1a1a] group-data-[role=user]:min-h-9 group-data-[role=user]:max-w-[80%] group-data-[role=user]:rounded-2xl group-data-[role=user]:border group-data-[role=user]:border-[#f0f0f0] group-data-[role=user]:bg-white group-data-[role=user]:px-3 group-data-[role=user]:py-1.5 group-data-[role=user]:shadow-xs dark:text-[#fcfcfc] dark:group-data-[role=user]:border-[#262626] dark:group-data-[role=user]:bg-[#181818]">
                    {message.text}
                  </Message.Text>
                </Message.Root>
              ))}
            </Thread.Content>
          </div>
        </Thread.Viewport>
        <Thread.Composer className="absolute inset-x-0 bottom-0 z-2 w-full">
          <div className="flex w-full flex-col items-center px-4 pb-4">
            <Composer.Root onSubmit={handleSubmit} className="flex w-full flex-col">
              <Composer.Container className="cursor-text rounded-2xl border border-[#f0f0f0] bg-white shadow-xs transition-colors focus-within:border-[#ececec] dark:border-[#262626] dark:bg-[#181818] dark:focus-within:border-[#2d2d2d]">
                <Composer.Textarea className="max-h-32 min-h-8 overflow-y-auto px-4 pt-3 text-sm **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-[1.7] [&_[data-composer-editor]:focus]:outline-none">
                  <Composer.Placeholder
                    placeholder="Send a message…"
                    className="leading-[1.7] text-[#949494] dark:text-[#6f6f6f]"
                  />
                </Composer.Textarea>
                <Composer.Actions className="flex justify-end gap-2 p-2">
                  <Composer.Submit className="flex size-8 items-center justify-center rounded-full bg-[#1a1a1a] text-white transition-opacity disabled:opacity-40 dark:bg-[#fcfcfc] dark:text-[#111111]">
                    <SendIcon />
                  </Composer.Submit>
                </Composer.Actions>
              </Composer.Container>
            </Composer.Root>
          </div>
        </Thread.Composer>
      </Thread.Root>
    </div>
  );
};

const SendIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="M8 13V3m0 0L3.5 7.5M8 3l4.5 4.5" />
  </svg>
);
```

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:

```tsx
"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:

```ts
// 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 `UIMessage`s, 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](/primitives/composer#performance) — keeping the composer from re-rendering on every chunk
- [Composer](/primitives/composer) — commands, chips, attachments, and the ask-user flow
- [Steps](/primitives/steps) and [Reasoning](/primitives/reasoning) — surfacing tool calls and thinking
