Building a chat app

Four components compose into a complete streamed chat: a stick-to-bottom Conversation, Message rows, a streaming-safe Response renderer, and the Composer well. Everything is controlled and transport-agnostic — the prop shapes line up with the AI SDK's useChat, but nothing here depends on it.

SU

Ask me about seamui's motion.

The pieces

  • Conversation — the scroll viewport; sticks to the bottom while tokens stream, releases when you scroll up.
  • Message — one row: an assistant avatar + content, or a right-aligned user key.
  • Response — renders assistant markdown safely mid-stream (no broken fences while a code block is half-typed).
  • Composer — the debossed prompt well; its status swaps send ⇄ stop.
bash
bunx --bun seamui@latest add composer conversation message response

1. The viewport

Conversation owns the scroll. It pins to the bottom as content grows and drops the pin the moment the reader scrolls up; the ConversationScrollButton floats in to jump back down.

tsx
<Conversation>
  <ConversationContent>
    {messages.map((m) => (
      <Message key={m.id} from={m.from}>…</Message>
    ))}
  </ConversationContent>
  <ConversationScrollButton />
</Conversation>

2. Messages

A Message's from decides its side. The user's text is a raised key; the assistant sits flat on the canvas with an avatar, its markdown going through Response so it stays intact while streaming.

tsx
<Message from={m.from}>
  {m.from === "assistant" && <MessageAvatar name="Seam UI" />}
  <MessageContent>
    {m.from === "assistant" ? <Response>{m.text}</Response> : m.text}
  </MessageContent>
</Message>

3. The composer

The Composer is the signature debossed well. Pass a status of "ready" or "streaming" and ComposerSubmit renders send or stop automatically — wire onStop to cancel the stream. Enter submits, Shift+Enter adds a newline.

tsx
<Composer status={status} onStop={stop} onSubmit={submit}>
  <ComposerTextarea
    value={value}
    onChange={(e) => setValue(e.target.value)}
    placeholder="Ask anything…"
  />
  <ComposerToolbar>
    <ComposerSubmit disabled={status === "ready" && !value.trim()} />
  </ComposerToolbar>
</Composer>

4. Wire it up

Hold the messages in state, append a user message and an empty assistant message on submit, then grow the assistant's text as tokens arrive. Here it's a fake streaming backend; swapping in a real one (or the AI SDK's useChat) means replacing the setInterval with your stream — the components don't change.

tsx
"use client"

import * as React from "react"

import {
  Composer,
  ComposerSubmit,
  ComposerTextarea,
  ComposerToolbar,
} from "@/registry/seam/ui/composer"
import {
  Conversation,
  ConversationContent,
  ConversationScrollButton,
} from "@/registry/seam/ui/conversation"
import {
  Message,
  MessageAvatar,
  MessageContent,
} from "@/registry/seam/ui/message"
import { Response } from "@/registry/seam/ui/response"

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

const REPLY = `Good question. A few notes:

- **springs** carry velocity, so interruptions redirect smoothly
- **depth** presses controls into the surface and floats overlays up
- **reduced motion** swaps movement for opacity — never a dead UI

That is the whole seam idea in three lines.`

let nextId = 0

// A complete v0 chat: composer + conversation + message + response, wired to a
// fake streaming backend so the whole loop is visible.
export default function ChatDemo() {
  const [messages, setMessages] = React.useState<ChatMessage[]>([
    { id: nextId++, from: "assistant", text: "Ask me about seamui's motion." },
  ])
  const [value, setValue] = React.useState("")
  const [status, setStatus] = React.useState<"ready" | "streaming">("ready")
  const timers = React.useRef<ReturnType<typeof setInterval>[]>([])

  const stop = () => {
    timers.current.forEach(clearInterval)
    timers.current = []
    setStatus("ready")
  }

  const submit = (e: React.FormEvent) => {
    e.preventDefault()
    const text = value.trim()
    if (!text || status === "streaming") return
    setValue("")

    const replyId = nextId++
    setMessages((prev) => [
      ...prev,
      { id: nextId++, from: "user", text },
      { id: replyId, from: "assistant", text: "" },
    ])
    setStatus("streaming")

    const tokens = REPLY.split(/(\s+)/)
    let i = 0
    const id = setInterval(() => {
      i++
      const partial = tokens.slice(0, i).join("")
      setMessages((prev) =>
        prev.map((m) => (m.id === replyId ? { ...m, text: partial } : m))
      )
      if (i >= tokens.length) {
        clearInterval(id)
        timers.current = timers.current.filter((t) => t !== id)
        setStatus("ready")
      }
    }, 55)
    timers.current.push(id)
  }

  return (
    <div className="bg-card flex h-[28rem] w-full max-w-md flex-col overflow-hidden rounded-xl squircle border shadow-resting">
      <Conversation>
        <ConversationContent>
          {messages.map((m) => (
            <Message key={m.id} from={m.from}>
              {m.from === "assistant" && <MessageAvatar name="Seam UI" />}
              <MessageContent>
                {m.from === "assistant" ? (
                  m.text ? (
                    <Response>{m.text}</Response>
                  ) : (
                    <span className="text-muted-foreground">…</span>
                  )
                ) : (
                  m.text
                )}
              </MessageContent>
            </Message>
          ))}
        </ConversationContent>
        <ConversationScrollButton />
      </Conversation>
      <div className="border-t p-2">
        <Composer status={status} onStop={stop} onSubmit={submit}>
          <ComposerTextarea
            value={value}
            onChange={(e) => setValue(e.target.value)}
            placeholder="Ask anything…"
          />
          <ComposerToolbar>
            <ComposerSubmit disabled={status === "ready" && !value.trim()} />
          </ComposerToolbar>
        </Composer>
      </div>
    </div>
  )
}

Accessibility & motion

Conversation is a role="log" with aria-live="polite", so streamed messages announce without you wiring anything. Newly appended text fades in (opacity only — safe under reduced motion); the scroll button rises at overlay depth and auto-scroll becomes an instant jump when prefers-reduced-motion is on. Nothing bounces the text.

Next steps