Skip to content
Elements

Attachments

Files stage inside the composer with per-file progress before the message sends.

screenshot.pnguploading
trace.log38 KB
fig. 01 ยท plays once, replay from the corner

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 init

Then 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.

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:

app/chat-provider.tsx
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

components/assistant-ui/elements/composer-attachments.tsx
"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 !== "running" && (
            <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.

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: uploading shows a spinner; done with an onRemove handler shows a remove button; done with no onRemove shows a check mark. 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.

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 sent

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

PartRendersNotes
Attachmentsrender prop{({ attachment }) => ReactNode}, once per staged file.
AttachmentByIndexwrapperRenders one attachment at a fixed index; used internally by Attachments.
AddAttachmentbuttonOpens 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.
AttachmentDropzonediv (or asChild)data-dragging="true" while a file drag is over it; drops call addAttachment per file.

AttachmentPrimitive

PartRendersNotes
RootdivScopes .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).
NametextThe attachment's name.
RemovebuttonCalls aui.attachment.remove().
unstable_ThumbdivRenders its children, or falls back to the file extension (or MIME type) as text.

Composer and attachment state

SelectorTypeDescription
s.composer.attachmentsreadonly Attachment[]Every file staged on this composer.
s.composer.attachmentAcceptstringThe configured adapter's accept, or "*".
s.thread.capabilities.attachmentsbooleanWhether an attachment adapter is configured at all.
s.attachment.name / .type / .contentType?stringIdentity of the attachment in scope.
s.attachment.statusPendingAttachmentStatus | CompleteAttachmentStatusSee 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" }.