React UI
Chat and composer
Compose chat threads, message lists, scroll controls, and prompt input with @anvia/react-ui.
ChatProvider binds a useChat(...) controller from @anvia/react to the UI primitives.
Everything inside the provider reads from the same messages, status, errors, suggestions, human
input state, and actions.
Use chat when the surface needs a transcript, follow-up turns, tools, attachments, suggestions, or human review. Use Completion instead when the surface has one prompt and one generated text result.
import { useChat } from "@anvia/react";
import { ChatProvider } from "@anvia/react-ui";
export function ChatSurface() {
const chat = useChat({ endpoint: "http://localhost:8787/api/chat" });
return <ChatProvider controller={chat}>{/* thread and composer */}</ChatProvider>;
}
The endpoint must return an Anvia stream. If you are building it from scratch, start with Server routes.
Thread
Thread.Root owns thread state. Thread.Viewport registers the scroll container, tracks whether
the user is at the bottom, and auto-scrolls while new messages arrive.
import { Thread } from "@anvia/react-ui";
export function ThreadArea() {
return (
<Thread.Root className="chat">
<Thread.Viewport className="chat-scroll" autoScroll>
<Thread.Empty className="empty-state">Ask your first question.</Thread.Empty>
<Thread.Suggestions className="suggestions" />
<Thread.Messages className="messages" />
<Thread.Error className="thread-error" />
<Thread.ViewportFooter>
<Thread.ScrollToBottom className="scroll-button">Latest</Thread.ScrollToBottom>
</Thread.ViewportFooter>
</Thread.Viewport>
</Thread.Root>
);
}.chat {
min-height: 0;
display: grid;
}
.chat-scroll {
min-height: 0;
overflow-y: auto;
padding: 24px 16px;
}
.messages,
.empty-state,
.suggestions,
.thread-error {
width: min(760px, 100%);
margin: 0 auto;
}
.messages {
display: grid;
gap: 16px;
}
.scroll-button[data-state="bottom"] {
visibility: hidden;
}import { Thread } from "@anvia/react-ui";
export function ThreadArea() {
return (
<Thread.Root className="grid min-h-0">
<Thread.Viewport className="min-h-0 overflow-y-auto px-4 py-6" autoScroll>
<Thread.Empty className="mx-auto w-full max-w-3xl">Ask your first question.</Thread.Empty>
<Thread.Suggestions className="mx-auto flex w-full max-w-3xl flex-wrap gap-2" />
<Thread.Messages className="mx-auto grid w-full max-w-3xl gap-4" />
<Thread.Error className="mx-auto w-full max-w-3xl" />
<Thread.ViewportFooter>
<Thread.ScrollToBottom className="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm data-[state=bottom]:invisible">
Latest
</Thread.ScrollToBottom>
</Thread.ViewportFooter>
</Thread.Viewport>
</Thread.Root>
);
}Thread.Messages renders the current messages. With no children it uses the default message
renderer. With a function child it lets the app control each row.
<Thread.Messages className="messages">
{(message) => (
<Message.Root className="message">
<Message.Content>
<Message.Parts />
</Message.Content>
</Message.Root>
)}
</Thread.Messages>
Use Thread.Status, Thread.Loading, and Thread.Error when the surface needs explicit lifecycle
UI:
<Thread.Status className="thread-status">
{(status) => (status === "streaming" ? "Generating..." : "Ready")}
</Thread.Status>
<Thread.Loading className="loading-row">Assistant is writing...</Thread.Loading>
<Thread.Error className="thread-error" />
Suggestions
Thread.Suggestions renders prompts configured with useChat({ suggestions }). Suggestions
unmount when empty unless keepMounted is enabled.
const chat = useChat({
endpoint: "http://localhost:8787/api/chat",
suggestions: [
{ id: "summarize", label: "Summarize", prompt: "Summarize this conversation." },
{ id: "next", label: "Next step", prompt: "Suggest the next step." },
],
});
<Thread.Suggestions className="suggestions">
{(suggestion) => (
<Thread.Suggestion className="suggestion" suggestion={suggestion}>
{suggestion.label}
</Thread.Suggestion>
)}
</Thread.Suggestions>
Composer
Composer.Root owns the draft text, selected entities, and pending attachments by default.
Composer.Input is a rich input that auto-resizes from minRows to maxRows, submits on Enter,
keeps Shift+Enter for new lines, and disables while the chat is streaming.
import { Composer } from "@anvia/react-ui";
export function PromptComposer() {
return (
<Composer.Root className="composer">
<Composer.Attachments className="attachments" />
<Composer.AddAttachment accept="image/*,.pdf" multiple>
Attach
</Composer.AddAttachment>
<Composer.Input minRows={1} maxRows={6} placeholder="Message Anvia..." />
<Composer.Stop>Stop</Composer.Stop>
<Composer.Submit>Send</Composer.Submit>
</Composer.Root>
);
}.composer {
width: min(760px, calc(100% - 32px));
margin: 0 auto 16px;
display: flex;
gap: 8px;
align-items: end;
border: 1px solid #3f3f46;
border-radius: 18px;
padding: 8px;
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.composer [data-anvia-composer-input] {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
background: transparent;
}
.composer[data-state="streaming"] [data-anvia-submit] {
display: none;
}import { Composer } from "@anvia/react-ui";
export function PromptComposer() {
return (
<Composer.Root className="mx-auto mb-4 flex w-[min(760px,calc(100%-32px))] items-end gap-2 rounded-[18px] border border-zinc-700 p-2 [&[data-state=streaming]_[data-anvia-submit]]:hidden">
<Composer.Attachments className="flex flex-wrap gap-2" />
<Composer.AddAttachment accept="image/*,.pdf" multiple>
Attach
</Composer.AddAttachment>
<Composer.Input className="min-w-0 flex-1 resize-none bg-transparent outline-none" minRows={1} maxRows={6} />
<Composer.Stop>Stop</Composer.Stop>
<Composer.Submit>Send</Composer.Submit>
</Composer.Root>
);
}Use controlled props when the draft, selected entities, or attachments need to be mirrored into app state.
<Composer.Root
input={draft}
onInputChange={setDraft}
attachments={attachments}
onAttachmentsChange={setAttachments}
>
<Composer.Attachments keepMounted />
<Composer.Input />
<Composer.Submit />
</Composer.Root>
Use triggers with Composer.Input and Composer.TriggerMenu for inline mentions, slash commands,
variables, or other entity chips.
<ChatProvider controller={chat}>
<Composer.Root
triggers={[
{
id: "people",
char: "@",
items: [{ id: "user_ada", label: "Ada Lovelace" }],
},
]}
>
<Composer.Input />
<Composer.TriggerMenu />
<Composer.Submit />
</Composer.Root>
</ChatProvider>
For a full trigger guide, see Composer triggers. Use
Composer.TextareaInput when the surface needs a native textarea instead of the rich composer.
Use submitMessage only when the submitted message itself needs custom metadata or attachment
handling. For request-level options such as model or reasoning effort, prefer createRequest on
useChat(...) as shown in Request shape.
<Composer.Root
submitMessage={async ({ input, attachments, entities, chat: chatController, clear }) => {
await chatController.sendMessage({
text: input,
attachments,
metadata: { source: "support-composer", composer: { entities } },
});
clear();
}}
>
<Composer.Input />
<Composer.Submit />
</Composer.Root>
Attachments
Use Composer.AddAttachment for a ready-made file-picker button, Composer.AttachmentInput when a
design system owns the input trigger, and Composer.AttachmentDropzone for drag-and-drop.
<Composer.Root>
<Composer.AttachmentDropzone className="dropzone">
<Composer.Attachments className="attachments" />
<Composer.AddAttachment accept="image/*,.pdf" multiple>
Attach image or PDF
</Composer.AddAttachment>
<Composer.Input />
<Composer.Submit />
</Composer.AttachmentDropzone>
</Composer.Root>
For a complete attachment recipe, see Composer attachments. For file handling across the route boundary, see Attachments end to end.
