Elements

Elements · AUI connected · AUI

Attachment

Runtime attachments for the composer and messages, with previews, progress, and removal.

Send a message...
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/attachment"
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.

Attachment renders one file as a small tile: a thumbnail, a hover tooltip with its name, and (while it's still staged in the composer) an upload spinner, an error state, and a remove button. With a runtime it reads the attachment straight off the composer or a sent message; there is no standalone form of this exact tile, since upload progress and removal only make sense against a live attachment. It comes in two designs: the runtime variant renders that live tile, and the static variant, MessageAttachments, renders a sent message's files as plain rows instead (see The received-files design).

Getting started

Thread already renders every piece below by default; use these directly if you're composing your own layout instead of Thread.

Render attachments in the composer

Wrap the input in ComposerPrimitive.AttachmentDropzone so dropped files are accepted, list staged attachments with ComposerPrimitive.Attachments, and add ComposerAddAttachment for the file picker button.

components/assistant-ui/elements/thread.aui.tsx
import { ComposerPrimitive } from "@assistant-ui/react";
import {
  ComposerAddAttachment,
  ComposerAttachments,
} from "@/components/assistant-ui/elements/attachment.aui";

function Composer() {
  return (
    <ComposerPrimitive.Root>
      <ComposerPrimitive.AttachmentDropzone>
        <ComposerAttachments />
        <ComposerPrimitive.Input placeholder="Send a message..." />
        <ComposerAddAttachment />
      </ComposerPrimitive.AttachmentDropzone>
    </ComposerPrimitive.Root>
  );
}

Render attachments on a sent message

UserMessageAttachments lists the files a user message actually carries, using MessagePrimitive.Attachments. It only ever renders inside a user message, since assistant messages don't carry attachments.

components/assistant-ui/elements/thread.aui.tsx
import { MessagePrimitive } from "@assistant-ui/react";
import { UserMessageAttachments } from "@/components/assistant-ui/elements/attachment.aui";

function UserMessage() {
  return (
    <MessagePrimitive.Root>
      <UserMessageAttachments />
      <MessagePrimitive.Parts />
    </MessagePrimitive.Root>
  );
}

Anatomy

<div> {/* AttachmentPrimitive.Root */}
  <button aria-label="Image attachment" /* or "Document attachment" / "File attachment", plus ", uploading" / ", upload failed" */>
    {/* thumbnail: the image itself, or a file icon fallback */}
    {/* uploading: a blurred spinner overlay */}
    {/* error: a blurred alert overlay */}
  </button>
  <button aria-label="Remove file">{/* composer only */}</button>
</div>

The remove button and the upload/error overlays only render on a composer attachment; a message attachment shows the tile alone. A lone image attachment on a message renders larger than the rest. Clicking an image tile whose source resolves opens a fullscreen preview dialog; any other kind of tile, or an image with no resolvable source yet, renders the click target with no dialog behavior at all.

Examples

Composer vs. message attachments

Both exports render the same tile; only the source differs. aui.attachment.source is "composer" for a staged attachment and "message" for one already sent, and that's exactly what decides whether the remove button and drag-in animation appear.

<ComposerAttachments />       {/* ComposerPrimitive.Attachments, source: "composer" */}
<UserMessageAttachments />    {/* MessagePrimitive.Attachments, source: "message" */}

Reading the resolved preview source

useAttachmentSrc() resolves an image attachment's displayable URL: an object URL for a file still in memory, or the first image content part once the attachment has uploaded. Anything that isn't an image resolves to undefined, which is exactly the signal a custom preview uses to fall back to an icon.

import { useAttachmentSrc } from "@/hooks/use-attachment-src";

function CustomThumb() {
  const src = useAttachmentSrc();
  if (!src) return <FileIcon />;
  return <img src={src} alt="" />;
}

Add-attachment picker

ComposerAddAttachment opens a native file picker scoped to the composer's own attachmentAccept, and supports selecting more than one file at once. It renders nothing while the composer isn't editable (for example, while a run is streaming).

<ComposerAddAttachment />

API reference

Kit parts

PartRendersNotes
ComposerAttachmentslistWraps ComposerPrimitive.Attachments; one tile per staged attachment.
ComposerAddAttachmentbuttonWraps ComposerPrimitive.AddAttachment; opens the native file picker. Renders null while the composer isn't editable.
UserMessageAttachmentslistWraps MessagePrimitive.Attachments; one tile per attachment on the current user message.

Primitives composed

PartNotes
AttachmentPrimitive.RootWraps one tile; provides the attachment scope.
AttachmentPrimitive.NameThe attachment's file name, as text.
AttachmentPrimitive.RemoveCalls aui.attachment.remove().
ComposerPrimitive.AttachmentDropzoneSets data-dragging="true" while a file is dragged over it; no-ops when the thread's attachments capability is off.

Attachment state

SelectorTypeDescription
s.attachment.type"image" | "document" | "file" | stringKind of the current attachment.
s.attachment.namestringFile name.
s.attachment.status.type"running" | "requires-action" | "incomplete" | "complete""running" while uploading (carries a progress); "incomplete" with reason: "error" carries an optional message.
aui.attachment.source"message" | "composer"Which surface the current attachment belongs to.

The received-files design

The Static variant in the rail is a second design for a message's files: MessageAttachments renders an image as a filled thumbnail button and a document or file as an icon row, instead of the composer-style tile above. It is a single props-driven component with no runtime dependency:

npx shadcn@latest add "@assistant-ui/elements-message-attachment"

Wire it by mapping a user message's attachments into items; like UserMessageAttachments above, this only makes sense inside a user message. s.message.attachments is the store's own array and every entry is already complete (an upload still in progress lives in the composer's own attachments instead), so selecting it directly is cheap; deriving items still needs useMemo, since mapping to a fresh array on every call would re-render the list on every store update.

components/assistant-ui/elements/message-attachment.tsx
"use client";

import { useMemo } from "react";
import { useAuiState } from "@assistant-ui/react";
import {
  MessageAttachments,
  type MessageAttachmentItem,
} from "@/components/assistant-ui/elements/message-attachment";

function UserReceivedFiles() {
  const attachments = useAuiState((s) => s.message.attachments);
  const items = useMemo<MessageAttachmentItem[]>(
    () =>
      attachments.map((a) => ({
        id: a.id,
        name: a.name,
        size: formatFileSize(a),
        kind: a.type === "image" || a.type === "document" ? a.type : "file",
      })),
    [attachments],
  );

  if (items.length === 0) return null;
  return (
    <MessageAttachments attachments={items} onOpen={(id) => openViewer(id)} />
  );
}

The runtime carries id, type, name, and contentType, but no formatted size string or page count, so formatFileSize above is yours to write; a provider-defined custom type collapses to "file", since kind only accepts the three shown.

An image item renders as a filled thumbnail button with its name and size over the swatch, and a document or plain file renders as an icon row instead, with the page count appended only when pages is set. Both call onOpen(id) and neither opens anything on its own; wire onOpen to your own lightbox or download flow.

MessageAttachments

PropTypeDefaultDescription
attachmentsreadonly MessageAttachmentItem[]requiredThe received files, in order.
onOpen(id: string) => voidCalled when an attachment is clicked.
classNamestringMerged onto the root.

MessageAttachmentItem is { id: string; name: string; size: string; kind: "image" | "document" | "file"; pages?: number; swatch?: string }. All other div props are forwarded to the root.