--- 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 (
{ e.preventDefault(); handleSubmit(); }} className="w-full" > {attachments.length > 0 ? ( {attachments.map((item) => ( setAttachments((prev) => prev.filter((x) => attachmentKey(x) !== attachmentKey(item)), ) } /> ))} ) : null} setInput(e.target.value)} placeholder="Message with attachments…" disabled={isLoading} />
); } ``` 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; ```