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 files
npx 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
<Conversation
messages={messages}
streamingMessageId={streamingId ?? undefined}
renderContent={(content) => <Response parseIncompleteMarkdown>{content}</Response>}
welcomeTitle="Ask anything"
welcomeSuggestions={['Summarize this thread', 'Draft a reply']}
onWelcomeSelect={send}
/>
<PromptInput
onSubmit={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-busy and 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?