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.
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
statusswaps send ⇄ stop.
bunx --bun seamui@latest add composer conversation message response1. 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.
<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.
<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.
<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.
"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
- Render fenced code with Code Block, and agent steps with Tool.
- Ground answers using Sources and inline citations.
- Add a pre-first-token Typing Indicator, then prompt Suggestions.
- Give history temporal structure with Chat Timeline.