Attachments
Files stage inside the composer with per-file progress before the message sends.
Installation
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.
A file added to the composer becomes a chip: an icon for its kind, its name and size, and a trailing slot that carries a spinner while it uploads and a remove button once it is done. With a runtime the chip tracks a real upload through an attachment adapter; standalone you hold the list of files and their state yourself.
Getting started
A composer stages files the same way it stages text: aui.composer.addAttachment(file) adds one, an AttachmentAdapter turns it into a PendingAttachment and then a CompleteAttachment, and s.composer.attachments reflects the list at every step.
Configure an attachment adapter
Attachments are opt-in: without an adapter, s.thread.capabilities.attachments is false and AddAttachment, paste, and drop all no-op. assistant-ui ships adapters for the common cases:
import { useLocalRuntime, CompositeAttachmentAdapter, SimpleImageAttachmentAdapter, SimpleTextAttachmentAdapter } from "@assistant-ui/react";
const runtime = useLocalRuntime(chatModel, {
adapters: {
attachments: new CompositeAttachmentAdapter([
new SimpleImageAttachmentAdapter(),
new SimpleTextAttachmentAdapter(),
]),
},
});Add and list attachments
"use client";
import { ComposerPrimitive, AttachmentPrimitive } from "@assistant-ui/react";
import { PlusIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, ghostButton } from "@/components/assistant-ui/elements/surfaces";
export function ComposerAttachmentsRow() {
return (
<ComposerPrimitive.Attachments>
{({ attachment }) => (
<AttachmentPrimitive.Root
className={cn(field, "flex items-center gap-2.5 rounded-[14px] py-1.5 ps-1.5 pe-2.5")}
>
<span className="max-w-36 truncate text-xs font-medium">
<AttachmentPrimitive.Name />
</span>
{attachment.status.type === "complete" && (
<AttachmentPrimitive.Remove aria-label={`Remove ${attachment.name}`} className={cn(ghostButton, "size-5")}>
<XIcon className="size-3" />
</AttachmentPrimitive.Remove>
)}
</AttachmentPrimitive.Root>
)}
</ComposerPrimitive.Attachments>
);
}
export function AddAttachmentButton() {
return (
<ComposerPrimitive.AddAttachment aria-label="Add attachment" className={cn(ghostButton, "size-8")}>
<PlusIcon className="size-4" />
</ComposerPrimitive.AddAttachment>
);
}ComposerPrimitive.Attachments is a render-prop over every staged file; AttachmentPrimitive.Root scopes .Name, .Remove, and .unstable_Thumb to the attachment at that position, so they need no index or id passed in by hand. AddAttachment opens a native file picker filtered to s.composer.attachmentAccept (the adapter's accept, or every file type when none is configured).
Accept drag-and-drop
<ComposerPrimitive.AttachmentDropzone className="data-[dragging=true]:border-dashed data-[dragging=true]:bg-blue-500/[0.04]">
{/* the rest of the bar */}
</ComposerPrimitive.AttachmentDropzone>AttachmentDropzone sets data-dragging="true" while a file is dragged over it and stages every dropped file the same way addAttachment does; it claims the drop even without an adapter configured, so an unprevented drop never navigates the tab away to the file.
Standalone, a chip is a pure function of a ComposerAttachment object: nothing here uploads a file or tracks progress on its own. You own the array and mutate it as an upload proceeds.
Hold the attachment list
"use client";
import { useState } from "react";
import { ComposerAttachments, ComposerAttachmentChip, type ComposerAttachment } from "@/components/assistant-ui/elements/composer";
export function ChatBox() {
const [attachments, setAttachments] = useState<ComposerAttachment[]>([]);
const remove = (name: string) =>
setAttachments((prev) => prev.filter((a) => a.name !== name));
return (
<ComposerAttachments>
{attachments.map((attachment) => (
<ComposerAttachmentChip key={attachment.name} attachment={attachment} onRemove={remove} />
))}
</ComposerAttachments>
);
}Drive the upload yourself
async function upload(file: File) {
const attachment: ComposerAttachment = { name: file.name, meta: "0%", state: "uploading", progress: 0, kind: "image" };
setAttachments((prev) => [...prev, attachment]);
await uploadWithProgress(file, (progress) =>
setAttachments((prev) => prev.map((a) => (a.name === file.name ? { ...a, progress, meta: `${progress}%` } : a))),
);
setAttachments((prev) => prev.map((a) => (a.name === file.name ? { ...a, state: "done", meta: formatSize(file.size) } : a)));
}Anatomy
<div data-slot="composer-attachment" data-state={/* "uploading" | "done" | "error" */}>
<span>{/* icon: image, text, or archive, by kind */}</span>
<span>
<span>{/* name */}</span>
<span>{/* meta; turns red when state is "error" */}</span>
</span>
<span>{/* trailing slot */}</span>
{/* progress bar along the bottom edge, only while uploading */}
</div>The trailing slot has three outcomes, not two: uploading shows a spinner; done with an onRemove handler shows a remove button; done with no onRemove shows a plain check mark instead, since there is nothing to remove once nothing is watching for it. An "error" state shows none of the three (only the red meta text), matching the runtime's own "incomplete" status, which carries a message but no built-in retry affordance of its own.
Examples
Upload progress
aui.composer.getState().attachments[i].status is { type: "running", reason: "uploading", progress } while an adapter streams a PendingAttachment; read it to drive your own progress bar, or use AttachmentPrimitive.unstable_Thumb, which falls back to the file's extension or MIME type when no thumbnail is supplied.
const status = useAuiState((s) => s.attachment.status);
// { type: "running", reason: "uploading", progress: 42 } while uploading
// { type: "complete" } once sentThe catalog chip reads progress directly and widens a blue bar to match it while state is "uploading"; once you flip state to "done", the bar disappears entirely rather than sitting at full width.
<ComposerAttachmentChip attachment={{ name: "photo.png", meta: "1.2 MB", state: "uploading", progress: 62, kind: "image" }} />Restyle the chip
Both lanes take className on the outer element; the icon square, the two-line label, and the trailing slot are laid out with flex and don't need to move together.
<AttachmentPrimitive.Root className="rounded-2xl py-2" />Once the message is sent
Attachments in the composer are still local and removable; once a message sends, they live inside that message instead, read-only. Attachment covers the runtime message-side rendering, and its received-files design covers the equivalent standalone piece.
API reference
ComposerPrimitive
| Part | Renders | Notes |
|---|---|---|
Attachments | render prop | {({ attachment }) => ReactNode}, once per staged file. |
AttachmentByIndex | wrapper | Renders one attachment at a fixed index; used internally by Attachments. |
AddAttachment | button | Opens a native file picker filtered to 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) | data-dragging="true" while a file drag is over it; drops call addAttachment per file. |
AttachmentPrimitive
| Part | Renders | Notes |
|---|---|---|
Root | div | Scopes .Name, .Remove, and .unstable_Thumb to the attachment at this position. Must be inside ComposerPrimitive.Attachments (or MessagePrimitive.Attachments, for an attachment already on a sent message). |
Name | text | The attachment's name. |
Remove | button | Calls aui.attachment.remove(). |
unstable_Thumb | div | Renders its children, or falls back to the file extension (or MIME type) as text. |
Composer and attachment state
| Selector | Type | Description |
|---|---|---|
s.composer.attachments | readonly Attachment[] | Every file staged on this composer. |
s.composer.attachmentAccept | string | The configured adapter's accept, or "*". |
s.thread.capabilities.attachments | boolean | Whether an attachment adapter is configured at all. |
s.attachment.name / .type / .contentType? | string | Identity of the attachment in scope. |
s.attachment.status | PendingAttachmentStatus | CompleteAttachmentStatus | See below. |
aui.composer.addAttachment(file) | (file: File | CreateAttachment) => Promise<void> | Stages a file. |
aui.composer.clearAttachments() | () => Promise<void> | Removes every staged attachment. |
PendingAttachmentStatus is { type: "running", reason: "uploading", progress: number }, { type: "requires-action", reason: "composer-send" } (uploaded, waiting to be sent), or { type: "incomplete", reason: "error" | "upload-paused", message?: string }. CompleteAttachmentStatus is { type: "complete" }.
ComposerAttachment
| Field | Type | Description |
|---|---|---|
name | string | Shown as the chip's label. |
meta | string | The line under the name (size, percent, or an error hint). |
state | "uploading" | "done" | "error" | Drives the icon, the trailing slot, and the meta text color. |
progress | number | Width of the progress bar, out of 100, while state is "uploading". |
kind | "image" | "text" | "archive" | Selects the leading icon. @default renders the text icon |
ComposerAttachments / ComposerAttachmentChip
| Prop | Type | Default | Description |
|---|---|---|---|
attachment | ComposerAttachment | required | The file to render (ComposerAttachmentChip only). |
onRemove | (name: string) => void | Shown as a remove button once state is "done"; a check mark renders instead when omitted. | |
className | string | Merged onto the root. |