Proper UI

AI elements

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.

You

How do I stream a chat response into React without tying myself to one SDK?

Assistant

Here is the smallest setup that works with any streaming source:

  1. Keep the conversation in state: a list of messages and a status.
  2. Append each chunk to the last assistant message as it arrives.
  3. Flip status back to ready when the stream ends.
for await (const chunk of stream) {  setText((text) => text + chunk);}setStatus("ready");

Pass status to the prompt input and it swaps Send for Stop on its own. See the Proper UI docs (opens in a new tab) for more.

Installation

npx properui@latest add ai-elements

Wiring 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 to AIPromptInput's status, and status === "streaming" to AIConversation's and the last AIMessage's isStreaming.
  • message.role goes to AIMessage's from.
  • Each text part of a message goes to AIResponse; each reasoning part to AIReasoning; each tool part's name, state, input and output to AIToolCall; each source part to AISources.Source.
  • sendMessage({ text }) is your onSubmit, and stop is your onStop.
  • regenerate is the handler for a Regenerate AIMessage.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.

Assistant

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.

You

Summarise the release notes.

Assistant

Three changes: a new date range picker, RTL fixes across navigation, and semantic tokens for charts.

You

Which change affects theming?

Assistant

The chart tokens. Series colours now read from --color-utility-* so they follow .dark-mode.

You

Is that a breaking change?

Assistant

No. Old class names keep working; the tokens are additive.

You

Draft the changelog entry.

Assistant

Added semantic chart tokens so charts follow the active theme without extra props.

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.

Olivia Rhye

Can you rewrite this headline so it is shorter?

Assistant

Ship faster with components you own.

Response 3 / 3
Assistant
Generating response

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
  1. Run the CLI
  2. Import the component
  3. Ship it
Headings start at h3 by default, so a response never outranks your page.
npx @properui/cli@latest add ai-elements

Tables, images and raw HTML are shown as plain text on purpose.

Reasoning

Comparing the three options against the constraints in the brief…

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.

  • Dashboard screenshot.png
  • Project brief.pdf1.2 MB
0 / 4,000

Enter sends, Shift+Enter adds a line.

Model selector

Selected: Atlas Large

Branch

Assistant

Own your UI, and ship it sooner.

Response 3 / 3

FAQs