# Attachments



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";
import { Callout } from "@/components/callout";

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`**.

<DemoWithCode src="components/nexus-ui/examples/attachments/default.tsx">
  <AttachmentsDefault />
</DemoWithCode>

Installation [#installation]

<Tabs items={["CLI", "Manual"]} framed={false}>
  <Tab value="CLI">
    <Tabs items={["npm", "pnpm", "yarn", "bun"]}>
      <Tab value="npm">
        ```bash
        npx shadcn@latest add @nexus-ui/attachments
        ```
      </Tab>

      <Tab value="pnpm">
        ```bash
        pnpm dlx shadcn@latest add @nexus-ui/attachments
        ```
      </Tab>

      <Tab value="yarn">
        ```bash
        yarn dlx shadcn@latest add @nexus-ui/attachments
        ```
      </Tab>

      <Tab value="bun">
        ```bash
        bunx shadcn@latest add @nexus-ui/attachments
        ```
      </Tab>
    </Tabs>
  </Tab>

  <Tab value="Manual">
    <Steps>
      <Step>
        <h3>
          Install the following dependencies:
        </h3>

        <Tabs items={["npm", "pnpm", "yarn", "bun"]}>
          <Tab value="npm">
            ```bash
            npm install @radix-ui/react-slot class-variance-authority @hugeicons/react @hugeicons/core-free-icons
            ```
          </Tab>

          <Tab value="pnpm">
            ```bash
            pnpm add @radix-ui/react-slot class-variance-authority @hugeicons/react @hugeicons/core-free-icons
            ```
          </Tab>

          <Tab value="yarn">
            ```bash
            yarn add @radix-ui/react-slot class-variance-authority @hugeicons/react @hugeicons/core-free-icons
            ```
          </Tab>

          <Tab value="bun">
            ```bash
            bun add @radix-ui/react-slot class-variance-authority @hugeicons/react @hugeicons/core-free-icons
            ```
          </Tab>
        </Tabs>
      </Step>

      <Step>
        <h3>
          Copy and paste the following code into your project.
        </h3>

        <ComponentSource src="components/nexus-ui/attachments.tsx" title="components/nexus-ui/attachments.tsx" />
      </Step>

      <Step>
        <h3>
          Update import paths to match your project setup.
        </h3>
      </Step>
    </Steps>
  </Tab>
</Tabs>

Usage [#usage]

```tsx keepBackground
import {
  Attachments,
  AttachmentsDropOverlay,
  AttachmentTrigger,
  AttachmentList,
  Attachment,
  useAttachments,
  type AttachmentsContextValue,
} from "@/components/nexus-ui/attachments";
```

```tsx keepBackground noCollapse
<Attachments
  attachments={attachments}
  onAttachmentsChange={setAttachments}
  accept="image/*"
  multiple
>
  <AttachmentTrigger asChild>
    <button type="button">Add files</button>
  </AttachmentTrigger>
  <AttachmentList>
    {attachments.map((a) => (
      <Attachment
        key={`${a.name}-${a.size}`}
        variant="inline"
        attachment={a}
        onRemove={() =>
          setAttachments((prev) => prev.filter((x) => x !== a))
        }
      />
    ))}
  </AttachmentList>
</Attachments>
```

<Callout type="warning">
  `AttachmentTrigger` and any component that opens the file picker must be rendered inside `Attachments`. If mounted outside, the picker will not open because it cannot access shared context.
</Callout>

Examples [#examples]

Detailed variant [#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**).

<DemoWithCode src="components/nexus-ui/examples/attachments/variant-detailed.tsx">
  <AttachmentsVariantDetailed />
</DemoWithCode>

Inline variant [#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.

<DemoWithCode src="components/nexus-ui/examples/attachments/variant-inline.tsx">
  <AttachmentsVariantInline />
</DemoWithCode>

Pasted text variant [#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.

<DemoWithCode src="components/nexus-ui/examples/attachments/variant-pasted.tsx">
  <AttachmentsVariantPasted />
</DemoWithCode>

Upload progress [#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.

<DemoWithCode src="components/nexus-ui/examples/attachments/with-progress.tsx">
  <AttachmentsWithProgress />
</DemoWithCode>

With Prompt Input [#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`**).

<DemoWithCode src="components/nexus-ui/examples/attachments/with-prompt-input.tsx">
  <AttachmentsWithPromptInput />
</DemoWithCode>

Vercel AI SDK Integration [#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.

<Steps>
  <Step>
    <h3>
      Install the AI SDK
    </h3>

    ```bash
    npm install ai @ai-sdk/react @ai-sdk/openai
    ```
  </Step>

  <Step>
    <h3>
      Create your chat API route
    </h3>

    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.
  </Step>

  <Step>
    <h3>
      Wire Prompt Input, Attachments, and 

      `sendMessage`
    </h3>

    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<AttachmentMeta[]>([]);
      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 (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            handleSubmit();
          }}
          className="w-full"
        >
          <Attachments
            attachments={attachments}
            onAttachmentsChange={setAttachments}
            accept="image/*"
            multiple
            disabled={isLoading}
          >
            <PromptInput onSubmit={handleSubmit}>
              {attachments.length > 0 ? (
                <AttachmentList className="px-3 pt-3">
                  {attachments.map((item) => (
                    <Attachment
                      key={attachmentKey(item)}
                      variant="inline"
                      attachment={item}
                      onRemove={() =>
                        setAttachments((prev) =>
                          prev.filter((x) => attachmentKey(x) !== attachmentKey(item)),
                        )
                      }
                    />
                  ))}
                </AttachmentList>
              ) : null}
              <PromptInputTextarea
                value={input}
                onChange={(e) => setInput(e.target.value)}
                placeholder="Message with attachments…"
                disabled={isLoading}
              />
              <PromptInputActions>
                <PromptInputActionGroup>
                  <PromptInputAction>
                    <AttachmentTrigger asChild>
                      <Button
                        type="button"
                        variant="ghost"
                        size="icon-sm"
                        className="cursor-pointer rounded-full text-secondary-foreground active:scale-97 disabled:opacity-70 hover:dark:bg-secondary"
                        disabled={isLoading}
                      >
                        <HugeiconsIcon icon={PlusSignIcon} strokeWidth={2.0} className="size-4" />
                      </Button>
                    </AttachmentTrigger>
                  </PromptInputAction>
                </PromptInputActionGroup>
                <PromptInputActionGroup>
                  <PromptInputAction asChild>
                    <Button
                      type="submit"
                      size="icon-sm"
                      className="cursor-pointer rounded-full active:scale-97 disabled:opacity-70"
                      disabled={isLoading || !input.trim()}
                    >
                      {isLoading ? (
                        <HugeiconsIcon icon={SquareIcon} strokeWidth={2.0} className="size-3.5 fill-current" />
                      ) : (
                        <HugeiconsIcon icon={ArrowUp02Icon} strokeWidth={2.0} className="size-4" />
                      )}
                    </Button>
                  </PromptInputAction>
                </PromptInputActionGroup>
              </PromptInputActions>
            </PromptInput>
          </Attachments>
        </form>
      );
    }
    ```

    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.
  </Step>
</Steps>

API Reference [#api-reference]

Attachments [#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.

<TypeTable
  type={{
  attachments: {
    type: "AttachmentMeta[]",
    description:
      "Controlled list of attachment metadata shown in the UI and updated when the user picks files.",
  },
  onAttachmentsChange: {
    type: "(attachments: AttachmentMeta[]) => 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<HTMLInputElement>",
    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 [#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`**.

<TypeTable
  type={{
  variant: {
    type: '"fullscreen" | "contained"',
    default: '"fullscreen"',
    description:
      "`fullscreen`: portal to `document.body`, fixed full viewport. `contained`: absolute fill of the positioned parent.",
  },
  children: {
    type: "React.ReactNode",
    description:
      "Overlay content; default is a short “Drop files to attach” line.",
  },
  className: {
    type: "string",
    description: "Merged with the overlay shell classes.",
  },
}}
/>

Also extends **`React.HTMLAttributes<HTMLDivElement>`** (for example **`style`**, **`id`**) except **`children`** is typed explicitly above.

AttachmentTrigger [#attachmenttrigger]

Button (or slotted child) that opens the **`Attachments`** file dialog. Extends standard **`button`** props; supports **`asChild`** for composing with **`Button`**.

<TypeTable
  type={{
  asChild: {
    type: "boolean",
    default: "false",
    description:
      "Merge behavior onto the child element instead of rendering a `button`.",
  },
  className: {
    type: "string",
    description: "Additional CSS classes on the default `button`.",
  },
  disabled: {
    type: "boolean",
    description:
      "Native disabled attribute; combine with root `disabled` for full picker lockout.",
  },
  type: {
    type: '"button" | "submit" | "reset"',
    default: '"button"',
    description: "Button type when not using `asChild`.",
  },
}}
/>

AttachmentList [#attachmentlist]

Horizontal, scrollable row for attachment tiles. Sets **`role="list"`** by default. Extends **`React.HTMLAttributes<HTMLDivElement>`**.

<TypeTable
  type={{
  className: {
    type: "string",
    description:
      "Additional CSS classes (for example padding inside a prompt).",
  },
  role: {
    type: "string",
    default: '"list"',
    description: "ARIA role for the container.",
  },
}}
/>

Attachment [#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.

<TypeTable
  type={{
  variant: {
    type: '"compact" | "inline" | "detailed" | "pasted"',
    default: '"compact"',
    description:
      "`compact`: square preview; `inline`: horizontal chip with optional fade for long names; `detailed`: row with metadata; `pasted`: excerpt + Pasted footer for clipboard long-text (`AttachmentMeta.source: 'paste'`).",
  },
  attachment: {
    type: "AttachmentMeta",
    description: "Metadata driving preview, labels, and remove behavior.",
  },
  progress: {
    type: "number",
    description:
      "0–100; bottom progress bar when set and finite. Not shown for `pasted`.",
  },
  onRemove: {
    type: "() => 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 [#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.

<TypeTable
  type={{
  variant: {
    type: '"compact" | "inline" | "detailed" | "pasted"',
    description:
      "Optional override for preview framing; defaults to the parent `Attachment` variant.",
  },
  pastedExcerptMaxChars: {
    type: "number",
    default: "220",
    description: "`pasted` only: excerpt length before an ellipsis.",
  },
  className: {
    type: "string",
    description: "Additional CSS classes on the preview container.",
  },
}}
/>

AttachmentRemove [#attachmentremove]

Remove control for the current attachment. Default **`aria-label`** uses **`attachment.name`**. Merges **`onClick`** with **`onRemove`** from context. Extends **`button`** props; supports **`asChild`**.

<TypeTable
  type={{
  asChild: {
    type: "boolean",
    default: "false",
    description:
      "Merge onto the child element instead of rendering a `button`.",
  },
  position: {
    type: '"corner" | "center-end" | "inline"',
    description:
      "Icon position; defaults to `corner` for `compact` and `detailed`, and `center-end` for `inline`. Use `inline` for footer rows (e.g. `variant: 'pasted'`).",
  },
  className: {
    type: "string",
    description: "Additional CSS classes on the remove control.",
  },
  "aria-label": {
    type: "string",
    description:
      "Accessible label; falls back to `Remove ${attachment.name}`.",
  },
}}
/>

AttachmentInfo [#attachmentinfo]

Column wrapper for title and subtitle text in the **detailed** layout. Pure layout; extends **`React.HTMLAttributes<HTMLDivElement>`**.

<TypeTable
  type={{
  className: {
    type: "string",
    description: "Additional CSS classes on the info column.",
  },
}}
/>

AttachmentProperty [#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.

<TypeTable
  type={{
  as: {
    type: '"name" | "size" | "kind"',
    description:
      "`name`: file name; `size`: formatted `attachment.size`; `kind`: uppercased extension when the name has one.",
  },
  className: {
    type: "string",
    description: "Additional CSS classes on the rendered paragraph.",
  },
}}
/>

AttachmentProgress [#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**.

<TypeTable
  type={{
  value: {
    type: "number",
    description: "Progress from 0 to 100; width of the filled segment.",
  },
  className: {
    type: "string",
    description: "Additional CSS classes on the track container.",
  },
}}
/>

AttachmentMeta [#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 [#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<HTMLInputElement | null>;
  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;
```
