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.

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

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

What does Composer.Root render?
A form. Enter submits it, and onSubmit receives a ComposerSubmitData describing what was sent.
Does it clear itself afterwards?
Yes, once your handler runs. Anything you want to keep — the text, the files — is already in the data you were handed.
Can I stop an empty message from sending?
Check the text in onSubmit and return early, the way this demo does.
Where does the composer put its reply?
Nowhere — onSubmit hands you the text and you decide. Here it just appends to local state.
"use client";

import { Composer, type ComposerSubmitData } from "@intentface/chat/composer";
import { Message } from "@intentface/chat/message";
import { Thread, useThread } from "@intentface/chat/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 form. Enter submits it, 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 reserve goes 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 docked composer appending to local state. Thread measures the
// dock and publishes the reserve as --thread-overlay-bottom-height. 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-overlay-top-height:1rem]">
        {/* Measured by the primitive: a landed turn sits this far below the top. */}
        <Thread.Overlay
          direction="top"
          className="inset-x-0 top-0 z-1 h-(--thread-overlay-top-height) 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 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)">
              {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>
  );
};

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