How do I stream a chat response into React without tying myself to one SDK?
Accessible React components for AI chat interfaces, built with React Aria and styled with Tailwind CSS. Conversation, streaming messages, markdown responses, reasoning, tool calls, sources, suggestions, a prompt input and a model selector, with no dependency on any AI SDK.
Installation
npx properui@latest add ai-elementsWiring it to a stream
The components take plain props and callbacks, so they work with any source that yields text: fetch with a
ReadableStream, server-sent events, a WebSocket, or a chat hook from an SDK. Keep the messages and a status in
state, append each chunk to the last assistant message, and hand status to AIPromptInput:
const [messages, setMessages] = useState<{ id: string; from: "user" | "assistant"; text: string }[]>([]);
const [status, setStatus] = useState<AIPromptInputStatus>("ready");
const controller = useRef<AbortController>(null);
const send = async ({ text }: AIPromptSubmission) => {
const id = crypto.randomUUID();
setMessages((m) => [...m, { id: `${id}-q`, from: "user", text }, { id, from: "assistant", text: "" }]);
setStatus("streaming");
controller.current = new AbortController();
try {
// Any async iterable of strings works here.
for await (const chunk of streamCompletion(text, controller.current.signal)) {
setMessages((m) => m.map((msg) => (msg.id === id ? { ...msg, text: msg.text + chunk } : msg)));
}
setStatus("ready");
} catch {
setStatus(controller.current.signal.aborted ? "ready" : "error");
}
};
return (
<>
<AIConversation isStreaming={status === "streaming"} className="h-160">
<AIConversation.Content>
{messages.map((msg, index) => (
<AIMessage key={msg.id} from={msg.from} isStreaming={status === "streaming" && index === messages.length - 1}>
{msg.from === "assistant" ? <AIResponse>{msg.text}</AIResponse> : msg.text}
</AIMessage>
))}
</AIConversation.Content>
<AIConversation.ScrollButton />
</AIConversation>
<AIPromptInput status={status} onSubmit={send} onStop={() => controller.current?.abort()} />
</>
);
Using a chat hook
Nothing here depends on the Vercel AI SDK, but its useChat hook maps onto these props one to one, so you can pass its
values straight through:
status("ready" | "submitted" | "streaming" | "error") goes toAIPromptInput'sstatus, andstatus === "streaming"toAIConversation's and the lastAIMessage'sisStreaming.message.rolegoes toAIMessage'sfrom.- Each text part of a message goes to
AIResponse; each reasoning part toAIReasoning; each tool part's name, state, input and output toAIToolCall; each source part toAISources.Source. sendMessage({ text })is youronSubmit, andstopis youronStop.regenerateis the handler for a RegenerateAIMessage.Action.
Any other hook with the same shape (a message list, a status, send and stop functions) wires up the same way.
Streaming
AIMessage with isStreaming shows shimmering placeholder lines until the first token arrives, then a blinking caret
after the last line of text. AIReasoning opens while its own isStreaming is true and collapses to
Thought for N seconds when it turns false. The animations are CSS only and stop under prefers-reduced-motion.
Conversation
AIConversation sticks to the bottom while content grows, lets go as soon as the reader scrolls up, and shows
AIConversation.ScrollButton until they return. The log is a keyboard-focusable role="log" region with
aria-live="polite"; while isStreaming is set it is marked aria-busy, so screen readers announce the finished
message once instead of every chunk.
Message
User messages sit in a bubble at the end of the row; assistant messages span it. Every message is attributed to its
author for screen readers even when no name is shown. Put copy, regenerate and feedback buttons in actions with
AIMessage.Actions and AIMessage.Action; pass isSelected to make an action a toggle.
Response
AIResponse is a small, dependency-free renderer for the markdown chat models produce. It is safe to call on every
streamed chunk: an unclosed code fence becomes a code block that runs to the end, and an unclosed ** or backtick
stays literal until it is closed.
It supports headings (# to ######, rendered from h3 down by default, see headingLevel), paragraphs, one level of
-, *, + and numbered lists, > blockquotes, --- rules, **bold**, *italic*, inline code, [links](url),
and fenced code blocks, which are highlighted by the same tokeniser as CodeSnippet.
It does not support tables, images, nested lists, setext headings, indented code blocks, reference-style links,
footnotes, strikethrough, task lists, math or backslash escapes. Raw HTML is never interpreted, and links with a scheme
other than http, https, mailto or tel render as text. Links open in a new tab with rel="noopener noreferrer".
If you need full CommonMark or GFM, render the text with your own markdown library inside AIMessage instead.
Getting started
Install the entry, then import it from its subpath so bundlers keep only what you use. Inline code, emphasis and links (opens in a new tab) all work.
What you get
- Accessible primitives built on React Aria
- Semantic tokens that follow the active theme
- Logical properties, so
dir="rtl"just works
- Run the CLI
- Import the component
- Ship it
Headings start at h3 by default, so a response never outranks your page.npx @properui/cli@latest add ai-elementsTables, images and raw HTML are shown as plain text on purpose.
Reasoning
Tool call
Parameters
{ "title": "Tooltip clips in RTL", "labels": [ "bug", "rtl" ]}Result
{ "id": 482, "url": "https://example.com/issues/482"}Sources
Suggestions
Pick a suggestion.
Prompt input
Enter sends and Shift+Enter adds a line (an IME composition is never interrupted). The field grows with its content up
to a maximum height, then scrolls. Empty prompts are never sent. While status is submitted or streaming, the send
button becomes Stop and Enter does nothing.
Picked images get an object URL for their preview. Removing an attachment revokes it; after a submit the URLs are
handed to you with the attachments, so revoke them with URL.revokeObjectURL once you no longer display them.
Enter sends, Shift+Enter adds a line.
Model selector
Selected: Atlas Large