Composer
The unified input: attachments, commands, mentions, models, voice, and context in one surface.
Context
37%
Installation
Install manually
npx shadcn@latest add "@assistant-ui/elements-composer"First time? Set up a runtime
Runtime components read their state from an assistant-ui runtime. Add one to an existing project:
npx assistant-ui@latest initThen wrap your app in a runtime provider:
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";
export default function App() {
const runtime = useChatRuntime({
transport: new AssistantChatTransport({ api: "/api/chat" }),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{/* your components */}
</AssistantRuntimeProvider>
);
}The installation guide covers new projects, templates, and API routes.
npx shadcn@latest add "@assistant-ui/elements-composer"Props-driven: no runtime or provider required.
The composer is the rounded bar where a person writes the next message: a growing text field with a toolbar underneath for attachments, the active model, and a send button that swaps to a stop button mid-run. With a runtime the bar reads and writes the thread's live composer state; standalone you hold that state yourself.
Getting started
Every assistant-ui runtime carries a composer scoped to the active thread (or to a message being edited): a place to hold the draft text, staged attachments, and the send and cancel actions. Build the bar from ComposerPrimitive, the same primitives the Thread element renders internally, and pair them with the shared surface tokens from surfaces.tsx to match this catalog's look.
Compose the bar
"use client";
import { AuiIf, ComposerPrimitive } from "@assistant-ui/react";
import { ArrowUpIcon, PlusIcon, SquareIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { ghostButton, inkButton, paper } from "@/components/assistant-ui/elements/surfaces";
export function ComposerBar() {
return (
<ComposerPrimitive.Root className={cn(paper, "flex w-full max-w-lg flex-col gap-2 rounded-[24px] p-2.5")}>
<ComposerPrimitive.Input
placeholder="Message..."
rows={1}
className="placeholder:text-muted-foreground min-h-11 w-full resize-none bg-transparent px-3 text-[15px] outline-none"
/>
<div className="flex items-center justify-between">
<ComposerPrimitive.AddAttachment
aria-label="Add attachment"
className={cn(ghostButton, "size-8 disabled:pointer-events-none disabled:opacity-30")}
>
<PlusIcon className="size-4" />
</ComposerPrimitive.AddAttachment>
<AuiIf condition={(s) => !s.thread.isRunning}>
<ComposerPrimitive.Send
aria-label="Send message"
className={cn(inkButton, "grid size-8 place-items-center rounded-full")}
>
<ArrowUpIcon className="size-4" />
</ComposerPrimitive.Send>
</AuiIf>
<AuiIf condition={(s) => s.thread.isRunning}>
<ComposerPrimitive.Cancel
aria-label="Stop generating"
className={cn(inkButton, "grid size-8 place-items-center rounded-full")}
>
<SquareIcon className="size-3 fill-current" />
</ComposerPrimitive.Cancel>
</AuiIf>
</div>
</ComposerPrimitive.Root>
);
}ComposerPrimitive.Root renders a <form> that submits on Enter (Shift+Enter for a newline) and sends whatever text and attachments are staged. AuiIf swaps Send for Cancel the moment the thread starts running, the same swap Thread's own composer makes.
Add the rest as you need it
Attachments, the / and @ menus, the model trigger, and dictation are each their own primitives layered onto this same bar. See Attachments, Slash commands, Mentions, Models, and Dictation for each piece's own wiring. The Thread element ships a complete composer built from these same primitives, so installing Thread gives you a working bar without assembling one yourself; this element is for building your own bar, or for a surface Thread does not cover, such as a compact inline composer.
Standalone, Composer and its children are presentational: they render the shape and animate the visible states, but hold no text, attachments, or run status of their own. You own that state and pass it down as props and event handlers.
Hold the composer state
"use client";
import { useState } from "react";
import { Composer, ComposerBar, ComposerInput, ComposerToolbar, ComposerActions, ComposerAttachButton, ComposerSend } from "@/components/assistant-ui/elements/composer";
export function ChatBox() {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const send = () => {
if (!text.trim()) return;
setSending(true);
submitMessage(text).finally(() => setSending(false));
setText("");
};
return (
<Composer>
<ComposerBar>
<ComposerInput
value={text}
onChange={(e) => setText(e.target.value)}
onSubmit={send}
placeholder="Message..."
/>
<ComposerToolbar>
<ComposerAttachButton onClick={() => addAttachment()} />
<ComposerActions>
<ComposerSend streaming={sending} idle={text.trim().length === 0} onClick={send} />
</ComposerActions>
</ComposerToolbar>
</ComposerBar>
</Composer>
);
}Read the toolbar's own behavior
ComposerAttachButton disables itself when it receives no onClick, so an unwired button never renders as clickable. ComposerSend never disables itself; it only changes appearance between a muted fill edged with the theme's border color (idle), the ink send button (!idle), and the ink stop button (streaming). To disable it, pass disabled, which is forwarded to the button.
Anatomy
<div data-slot="composer">
<div data-slot="composer-bar" data-drag-active={/* true while a file is dragged over */}>
<div data-slot="composer-attachments">{/* staged files, when any */}</div>
<input data-slot="composer-input" />
<div data-slot="composer-toolbar">
<button data-slot="composer-attach" />
<div data-slot="composer-actions">
{/* model trigger, voice button, context ring, ... */}
<button data-slot="composer-send" />
</div>
</div>
</div>
</div>ComposerBar takes dragActive, which tints it as a drop target, and ComposerSend switches its fill and icon from streaming and idle. The other pieces hold no state; the composition (what sits in the toolbar, whether the model trigger or voice button appears) is up to the page that assembles them.
Examples
Swapping send for cancel
AuiIf mounts exactly one of the two, matching how Thread's own composer switches them. On a runtime with the queue capability, Send would stay enabled during a run so a message can be queued; this composition unmounts it for the length of the run instead:
<ComposerActions>
<AuiIf condition={(s) => !s.thread.isRunning}>
<ComposerPrimitive.Send className={cn(inkButton, "grid size-8 place-items-center rounded-full")}>
<ArrowUpIcon className="size-4" />
</ComposerPrimitive.Send>
</AuiIf>
<AuiIf condition={(s) => s.thread.isRunning}>
<ComposerPrimitive.Cancel className={cn(inkButton, "grid size-8 place-items-center rounded-full")}>
<SquareIcon className="size-3 fill-current" />
</ComposerPrimitive.Cancel>
</AuiIf>
</ComposerActions>A single ComposerSend swaps its own icon; drive streaming from whatever you consider "in flight":
<ComposerActions>
<ComposerSend streaming={isSending} idle={text.length === 0} onClick={isSending ? cancel : send} />
</ComposerActions>Restyle the bar
Every piece here takes className, and the rounded corners, borders, and hover states all come from the shared paper, ghostButton, and inkButton tokens in surfaces.tsx. Restyling those tokens restyles every element in the catalog that uses them, not only the composer.
<ComposerBar className="max-w-2xl rounded-2xl p-4" />API reference
ComposerPrimitive
| Part | Renders | Notes |
|---|---|---|
Root | form | Sends on submit, which Input triggers on Enter; compact sets data-compact while the text holds at most one line and there are no attachments, quote, queued messages, or active dictation, so styles can collapse it to a single row. |
Input | auto-resizing textarea | Controlled by the runtime; disabled while the thread is disabled or dictation is active. submitMode ("enter" | "ctrlEnter" | "none") controls Enter's behavior. |
Send | button | Disabled while !s.composer.canSend, and also while the thread is running unless it has the queue capability. |
Cancel | button | Disabled while !s.composer.canCancel. |
AddAttachment | button | Opens a native file picker filtered by attachmentAccept; disabled only while the composer is not editable, so a pick made with no adapter configured opens the picker but silently fails to add the file. |
AttachmentDropzone | div (or asChild) | Sets data-dragging="true" while a file is dragged over; drops call addAttachment per file. |
Composer state
| Selector | Type | Description |
|---|---|---|
s.composer.text | string | The current draft text. |
s.composer.isEmpty | boolean | Whether the composer has no text and no attachments. |
s.composer.canSend | boolean | Whether send() would do anything right now. |
s.composer.canCancel | boolean | Whether cancel() would do anything right now. |
s.composer.attachments | readonly Attachment[] | Files staged on this composer; see Attachments. |
s.thread.isRunning | boolean | Whether the thread has a run in flight. |
aui.composer.send(options?) | (options?: { startRun?: boolean; steer?: boolean }) => void | Sends the staged text and attachments. |
aui.composer.cancel() | () => void | Cancels the current run, or exits edit mode. |
Composer
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Merged onto the root div. |
All other div props are forwarded.
ComposerBar
| Prop | Type | Default | Description |
|---|---|---|---|
dragActive | boolean | false | Tints the bar as a drop target and sets data-drag-active. |
className | string | Merged onto the root. |
ComposerInput
| Prop | Type | Default | Description |
|---|---|---|---|
onSubmit | () => void | Called on Enter, with or without modifiers, except mid-IME composition or when your own onKeyDown calls preventDefault(). | |
className | string | Merged onto the input. |
All other input props are forwarded.
ComposerToolbar / ComposerActions
Plain divs (data-slot="composer-toolbar" and data-slot="composer-actions") that lay out their children in a row; both forward every div prop.
ComposerAttachButton
| Prop | Type | Default | Description |
|---|---|---|---|
onClick | () => void | Required for the button to render enabled; omitting it renders it disabled. |
ComposerSend
| Prop | Type | Default | Description |
|---|---|---|---|
streaming | boolean | required | Shows the stop icon and the ink fill. |
idle | boolean | required | Whether there is nothing to send. Shows a muted fill with a border-colored edge while true and not streaming; the ink fill otherwise. |