---
title: Attachments
description: Composable file attachments for chat inputs and messages with preview, variants, and upload wiring
---
import AttachmentsDefault from "@/components/nexus-ui/examples/attachments/default";
import AttachmentsVariantDetailed from "@/components/nexus-ui/examples/attachments/variant-detailed";
import AttachmentsVariantInline from "@/components/nexus-ui/examples/attachments/variant-inline";
import AttachmentsVariantPasted from "@/components/nexus-ui/examples/attachments/variant-pasted";
import AttachmentsWithPromptInput from "@/components/nexus-ui/examples/attachments/with-prompt-input";
import AttachmentsWithProgress from "@/components/nexus-ui/examples/attachments/with-progress";
Composable attachment UI for chat and messaging: thumbnails, type icons, or a **pasted-text** excerpt depending on **`Attachment`** **`variant`**, plus remove actions and optional upload progress. A controlled **`Attachments`** root owns the hidden file input and **`AttachmentTrigger`**; opt in to page drop (**`windowDrop`**, **`AttachmentsDropOverlay`**) and paste or custom flows via **`useAttachments`** and **`appendFiles`**.
## Installation
```bash
npx shadcn@latest add @nexus-ui/attachments
```
```bash
pnpm dlx shadcn@latest add @nexus-ui/attachments
```
```bash
yarn dlx shadcn@latest add @nexus-ui/attachments
```
```bash
bunx shadcn@latest add @nexus-ui/attachments
```
Copy and paste the following code into your project.
Update the import paths to match your project setup.
## Usage
```tsx keepBackground
import {
Attachments,
AttachmentsDropOverlay,
AttachmentTrigger,
AttachmentList,
Attachment,
useAttachments,
type AttachmentsContextValue,
} from "@/components/nexus-ui/attachments";
```
```tsx keepBackground noCollapse
{attachments.map((a) => (
setAttachments((prev) => prev.filter((x) => x !== a))
}
/>
))}
```
`AttachmentTrigger` and any other component that calls the file picker **must** be rendered **inside** `Attachments` so they receive context from the same provider as the hidden input.
The module also exports **`filesFromDataTransfer`**, **`toAttachmentMeta`**, **`AppendFilesOptions`**, and **`useAttachments`** for paste/drop and advanced wiring (see **With Prompt Input** and the **`useAttachments`** section).
## Examples
### Detailed variant
Wider tile with thumbnail, file name, and a second line: formatted **size** when **`attachment.size`** is set, otherwise a **kind** label from the file extension (uppercased, e.g. **PDF**, **XLSX**).
### Inline variant
Compact horizontal chip with thumbnail and file name. A hover fade sits over the trailing edge so long names can share space with the remove control.
### Pasted text variant
For **large pasted plain text** (e.g. from **`appendFiles([file], { paste: true })`** after pasting into your prompt), use **`variant="pasted"`** with **`AttachmentMeta.source === "paste"`**. The tile shows a short excerpt and a **Pasted** footer with remove.
### Upload progress
Pass **`progress`** (`0`–`100`) on **`Attachment`** to show a thin bar along the bottom of the tile for **`compact`**, **`inline`**, and **`detailed`**. The **`pasted`** variant does not render that bar (omit **`progress`** or it is ignored). Omit **`progress`** when the upload finishes.
### With Prompt Input
Wrap **[Prompt Input](/docs/components/prompt-input) with `Attachments`** (not the other way around) so **`AttachmentTrigger`**, the list, and the textarea stay in one **`Attachments`** tree. Enable **`windowDrop`** for page-level drag-and-drop (same validation as the picker: **`accept`**, **`maxFiles`**, **`maxSize`**, **`onFilesRejected`**). This example turns that on and adds **`AttachmentsDropOverlay`** (default **`fullscreen`**) for drag feedback—use **`variant="contained"`** inside a **`relative`** shell for in-box chrome only. Pasting **images** uses **`filesFromDataTransfer`**. Long **text** over a threshold becomes a **`text/plain`** file with **`appendFiles([file], { paste: true })`** so **`source: "paste"`** is set and the **`pasted`** tile is used (see **`with-prompt-input.tsx`**).
## Vercel AI SDK Integration
Use **Attachments** with the [Vercel AI SDK](https://sdk.vercel.ai) and [`useChat`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat): `sendMessage` accepts a **`files`** argument (**`FileList`** or an array of [`FileUIPart`](https://ai-sdk.dev/docs/reference/ai-sdk-core/ui-message) objects). The SDK turns them into user message parts for multimodal models.
See [Prompt Input](/docs/components/prompt-input#vercel-ai-sdk-integration) for a minimal chat API route. The same route works when messages include **file** parts—**`convertToModelMessages`** includes those parts in the model request.
Install the AI SDK
```bash
npm install ai @ai-sdk/react @ai-sdk/openai
```
Create your chat API route
Use the route from the Prompt Input docs, or ensure your handler calls **`streamText`** (or **`generateText`**) with **`messages: await convertToModelMessages(messages)`** so **file** parts are forwarded to the provider.
Wire Prompt Input, Attachments, and `sendMessage`
Build **`FileUIPart`** values from your **`AttachmentMeta`** list and pass them as **`files`** (URLs can be data URLs, HTTPS URLs, or blob URLs your app can read; for production, prefer stable URLs after upload). Put **`Attachments`** around **`PromptInput`** so **`AttachmentTrigger`** stays in context and you can later wrap the same **`Attachments`** subtree with a chat-wide drop zone if needed.
```tsx
"use client";
import { useCallback, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type FileUIPart } from "ai";
import { Button } from "@/components/ui/button";
import PromptInput, {
PromptInputAction,
PromptInputActionGroup,
PromptInputActions,
PromptInputTextarea,
} from "@/components/nexus-ui/prompt-input";
import {
Attachments,
Attachment,
AttachmentList,
AttachmentTrigger,
type AttachmentMeta,
} from "@/components/nexus-ui/attachments";
import { ArrowUp02Icon, PlusSignIcon, SquareIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
function attachmentKey(a: AttachmentMeta) {
return `${a.name ?? ""}-${a.size ?? ""}-${a.mimeType ?? ""}-${a.source ?? ""}-${a.url ?? ""}`;
}
function toFileParts(items: AttachmentMeta[]): FileUIPart[] {
return items
.filter((a) => a.url)
.map((a) => ({
type: "file" as const,
url: a.url!,
mediaType: a.mimeType ?? "application/octet-stream",
filename: a.name,
}));
}
export default function ChatWithAttachments() {
const { sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
});
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState([]);
const isLoading = status !== "ready";
const handleSubmit = useCallback(
(value?: string) => {
const trimmed = (value ?? input).trim();
const files = toFileParts(attachments);
if (!trimmed && files.length === 0) return;
sendMessage({
text: trimmed,
...(files.length ? { files } : {}),
});
setInput("");
setAttachments([]);
},
[attachments, input, sendMessage],
);
return (
);
}
```
The AI SDK [attachments guide](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot#attachments) also covers **`FileList`** and automatic conversion for **`image/*`** and **`text/*`** when you pass a native file input.
## API Reference
### Attachments
Controlled root: holds **`AttachmentMeta[]`**, wires **`onAttachmentsChange`**, renders a screen-reader-only **`input type="file"`**, optionally registers **`document`** drag-and-drop when **`windowDrop`** is true (opt-in), and exposes context for **`AttachmentTrigger`**, **`appendFiles`**, and **`isDraggingFile`**. Renders the input first, then **`children`**. Must wrap every **`AttachmentTrigger`** that opens its picker.
Object URLs created by this picker (**`URL.createObjectURL`** for every **`File`** chosen) are tracked and **`URL.revokeObjectURL`** when an attachment leaves the list or when **`Attachments`** unmounts. Blob URLs you attach yourself (outside this flow) are not revoked by the component. Only image and video tiles use **`url`** for built-in previews; other types keep the file icon.
void",
description:
"Called with the next list when files are chosen. New items are appended after `maxSize` and slot limits are applied.",
},
accept: {
type: "string",
description:
"Passed to the file input `accept` attribute. Also used to filter dropped files when using drag-and-drop.",
},
multiple: {
type: "boolean",
default: "true",
description: "Allow multiple files per dialog open.",
},
maxFiles: {
type: "number",
description: "Maximum total attachments; additional picks are truncated.",
},
maxSize: {
type: "number",
description: "Maximum size per file in bytes; larger files are skipped.",
},
disabled: {
type: "boolean",
default: "false",
description:
"Disables the file input and prevents `AttachmentTrigger` from opening the dialog.",
},
onFileInputChange: {
type: "React.ChangeEventHandler",
description:
"Optional. Fires after the internal change handler; the event still reflects selected files until the input value is cleared.",
},
onFilesRejected: {
type: "(detail: AttachmentsRejectedFiles) => void",
description:
"Optional. When files are not all appended: outside `accept` (`notAccepted`), oversize (`tooLarge`), over `maxFiles` (`overMaxFiles`), or extra when `multiple` is false (`truncatedByMultiple`).",
},
windowDrop: {
type: "boolean",
default: "false",
description:
"When true, registers `dragover` / `drop` on `document` so files can be dropped anywhere in the page.",
},
children: {
type: "React.ReactNode",
description:
"Triggers, lists, and surrounding layout (for example prompt chrome).",
},
}}
/>
```ts
export type AttachmentsRejectedFiles = {
notAccepted: File[];
tooLarge: File[];
overMaxFiles: File[];
truncatedByMultiple: File[];
};
```
### AttachmentsDropOverlay
Optional visual layer when **`isDraggingFile`** is true (set when **`windowDrop`** is enabled and a file drag is over the document). **`variant="fullscreen"`** (default) portals to **`document.body`** and covers the viewport; **`variant="contained"`** uses **`absolute inset-0`** — place inside a **`relative`** wrapper (e.g. prompt shell). **`pointer-events-none`** so drops still reach **`document`**. Default content is short copy; override with **`children`**. Must be rendered **inside** **`Attachments`**.
Also extends **`React.HTMLAttributes`** (for example **`style`**, **`id`**) except **`children`** is typed explicitly above.
### AttachmentTrigger
Button (or slotted child) that opens the **`Attachments`** file dialog. Extends standard **`button`** props; supports **`asChild`** for composing with **`Button`**.
### AttachmentList
Horizontal, scrollable row for attachment tiles. Sets **`role="list"`** by default. Extends **`React.HTMLAttributes`**.
### Attachment
One attachment tile. Chooses a default layout from **`variant`** unless **`children`** is provided. Renders **`AttachmentProgress`** when **`progress`** is a finite number **except** for **`variant="pasted"`**, which keeps the tile progress-free.
void",
description: "Called when the default remove control is activated.",
},
detailedSubtitle: {
type: '"size" | "kind"',
description:
"`detailed` only. Second line of text; when omitted, inferred from whether `attachment.size` is a positive number.",
},
children: {
type: "React.ReactNode",
description:
"Replaces the default layout when provided (custom composition).",
},
className: {
type: "string",
description: "Additional CSS classes on the tile root.",
},
pastedExcerptMaxChars: {
type: "number",
default: "220",
description: "`pasted` only: excerpt length before an ellipsis.",
},
}}
/>
### AttachmentPreview
Preview region inside an **`Attachment`**: raster image when **`thumbnailUrl`** or an image **`url`** exists, otherwise the type icon for **`attachment.type`**. For **`variant="pasted"`**, the preview is a multi-line **text excerpt** (from blob / **`data`** / **`url`** content) instead of an icon. Reads **`variant`** and **`attachment`** from context.
### AttachmentRemove
Remove control for the current attachment. Default **`aria-label`** uses **`attachment.name`**. Merges **`onClick`** with **`onRemove`** from context. Extends **`button`** props; supports **`asChild`**.
### AttachmentInfo
Column wrapper for title and subtitle text in the **detailed** layout. Pure layout; extends **`React.HTMLAttributes`**.
### AttachmentProperty
Renders a single line of text from **`attachment`**: file **name**, formatted **size**, or a **kind** label from the filename extension (uppercased). The **`as`** prop selects which field to show.
### AttachmentProgress
Thin horizontal progress bar along the bottom edge of a tile for **`compact`**, **`inline`**, and **`detailed`**. Usually passed via **`Attachment`** **`progress`**. Not used in the default **`pasted`** layout. **`value`** is clamped to **0–100**.
### AttachmentMeta
Metadata object for one attachment (not a React component). Used with **`Attachments`**, **`Attachment`**, and when mapping to AI SDK **`FileUIPart`** values.
```ts
export interface AttachmentMeta {
type: "image" | "file" | "video" | "audio";
name?: string;
url?: string;
/** Raster preview URL (e.g. PDF first page). When unset, preview uses the icon for `type`. */
thumbnailUrl?: string;
mimeType?: string;
size?: number;
width?: number;
height?: number;
data?: Blob | ArrayBuffer;
source?: "paste";
}
```
### useAttachments
Returns the full **`Attachments`** context (same source as internal primitives). Use **`isDraggingFile`** for custom drag chrome when **`windowDrop`** is on, **`appendFiles`** for custom drop targets (or your own **`onDrop`** handlers), **`openPicker`** / **`inputRef`** for advanced wiring. Throws if used outside **`Attachments`**.
```ts noCollapse
export type AppendFilesOptions = {
paste?: boolean;
};
export type AttachmentsContextValue = {
inputRef: React.RefObject;
inputId: string;
openPicker: () => void;
appendFiles: (files: File[], options?: AppendFilesOptions) => void;
isDraggingFile: boolean;
attachments: AttachmentMeta[];
onAttachmentsChange: (next: AttachmentMeta[]) => void;
accept?: string;
multiple: boolean;
maxFiles?: number;
maxSize?: number;
disabled: boolean;
};
export function useAttachments(): AttachmentsContextValue;
```