Build a React AI chat UI
A walkthrough of the production chat surface: message state, token streaming, markdown that survives partial tokens, an input with stop-generation, and feedback actions. Slipstream components are stateless — you own the transport, they render.
1. Install
bash
pnpm add @slipstream-ui/core @slipstream-ui/ai# or copy source filesnpx shadcn@latest add https://slipstreamui.com/registry/conversation.json
2. Own two pieces of state
The entire surface is driven by messages and an optional streamingMessageId. No context providers, no runtime lock-in — works with Vercel AI SDK, LangGraph, WebSockets, or a raw fetch stream.
tsx
const [messages, setMessages] = useState<MessageData[]>([]);const [streamingId, setStreamingId] = useState<string | null>(null);
3. Stream tokens as they arrive
Append the assistant message, set it as streaming, and update its content on every chunk. Response repairs half-finished markdown mid-stream, so a chunk ending in **half renders cleanly instead of showing raw asterisks.
tsx
async function send(text: string) {const replyId = crypto.randomUUID();setMessages((prev) => [...prev,{ id: crypto.randomUUID(), role: 'user', content: text },{ id: replyId, role: 'assistant', content: '' },]);setStreamingId(replyId);const res = await fetch('/api/chat', {method: 'POST',body: JSON.stringify({ prompt: text }),signal: controller.signal, // kept for stop-generation});const reader = res.body!.getReader();const decoder = new TextDecoder();let content = '';for (;;) {const { done, value } = await reader.read();if (done) break;content += decoder.decode(value, { stream: true });setMessages((prev) =>prev.map((m) => (m.id === replyId ? { ...m, content } : m)));}setStreamingId(null);}
4. Compose the surface
tsx
<Conversationmessages={messages}streamingMessageId={streamingId ?? undefined}renderContent={(content) => <Response parseIncompleteMarkdown>{content}</Response>}welcomeTitle="Ask anything"welcomeSuggestions={['Summarize this thread', 'Draft a reply']}onWelcomeSelect={send}/><PromptInputonSubmit={send}isLoading={streamingId !== null}onStop={() => controller.abort()}/>
- Conversation pins scroll to the latest message and exposes a scroll-to-latest pill, transcript download, and welcome state.
- PromptInput handles Enter-to-send, Shift+Enter, auto-resize, attachments, and flips to a Stop button while loading.
- MessageActions adds copy, thumbs up/down, and regenerate under the latest assistant message.
5. Accessibility is built in
- Streaming sets
aria-busyand announces completion politely. - Every component passes jest-axe suites; keyboard navigation works end-to-end.
- Reduced-motion variants respect
prefers-reduced-motion. - Empty transcripts render a configurable welcome state instead of a blank box.
Next steps
Was this page helpful?