---
title: Attachments
description: The attachment tray — structural slots for items, a remove affordance, a drop zone, and a file picker.
source: attachments
---

```tsx title="primitives/attachments/demos/basic.tsx"
"use client";

import { type AttachmentItem, Attachments } from "@intentface/chat/attachments";
import { type ComponentProps, useState } from "react";

// A removable strip driven by local state — the parts are structural slots and
// impose no media taxonomy, so icons and layout are yours to decide.
const INITIAL: AttachmentItem[] = [
  {
    id: "1",
    filename: "quarterly-report.pdf",
    mediaType: "application/pdf",
    url: "#",
    fileSize: 248_000,
  },
  { id: "2", filename: "meeting-notes.txt", mediaType: "text/plain", url: "#", fileSize: 1_200 },
];

export const Basic = () => {
  const [items, setItems] = useState<AttachmentItem[]>(INITIAL);

  if (items.length === 0) {
    return (
      <button
        type="button"
        onClick={() => setItems(INITIAL)}
        className="cursor-pointer rounded-full border border-[#f0f0f0] bg-white px-4 py-1.5 text-sm font-medium text-[#686868] transition-colors hover:bg-[#fafafa] dark:border-[#262626] dark:bg-[#181818] dark:text-[#9b9b9b] dark:hover:bg-[#232323]"
      >
        Reset
      </button>
    );
  }

  return (
    <Attachments.Root className="flex w-full max-w-md flex-wrap gap-2">
      {items.map((item) => (
        <Attachments.Item
          key={item.id}
          className="flex items-center gap-2 rounded-xl border border-[#f0f0f0] bg-white py-1.5 pr-1.5 pl-2.5 text-xs dark:border-[#262626] dark:bg-[#181818]"
        >
          <FileIcon className="text-[#949494]" />
          <span className="max-w-40 truncate">{item.filename}</span>
          <span className="text-[#949494] dark:text-[#6f6f6f]">
            {formatFileSize(item.fileSize)}
          </span>
          <Attachments.Remove
            onRemove={() => setItems((current) => current.filter((it) => it.id !== item.id))}
            filename={item.filename}
            className="flex size-5 cursor-pointer items-center justify-center rounded-full text-[#949494] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc]"
          >
            <CrossIcon />
          </Attachments.Remove>
        </Attachments.Item>
      ))}
    </Attachments.Root>
  );
};

const formatFileSize = (bytes?: number) => {
  if (!bytes) return "";
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};

const FileIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="14"
    height="14"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="M9 1.5H4.5A1.5 1.5 0 0 0 3 3v10a1.5 1.5 0 0 0 1.5 1.5h7A1.5 1.5 0 0 0 13 13V5.5L9 1.5Z" />
    <path d="M9 1.5v4h4" />
  </svg>
);

const CrossIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="12"
    height="12"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    aria-hidden="true"
    {...props}
  >
    <path d="m4.5 4.5 7 7m-7 0 7-7" />
  </svg>
);
```

## Usage guidelines

- **Pending-file tray** — the strip above the composer input, showing attachment chips with a remove affordance.
- **Model included** — accept matching and blob-URL lifecycle ship in the package; the tray's layout and motion are yours.
- **No media taxonomy** — `Attachments.Item` is a structural slot; read the item's `mediaType` and decide what an image, a PDF, or a file looks like.
- **Drop + pick** — a `Dropzone` overlay (in place, or portalled elsewhere via `portalSelector`) plus a `Trigger` file picker.
- **Get started** — see [Quick start](/quick-start) to add the package.

## Anatomy

```tsx
{items.length > 0 && (
  <Attachments.Root>
    {items.map((item) => (
      <Attachments.Item key={item.id}>
        <span>{item.filename}</span>
        <Attachments.Remove onRemove={() => remove(item.id)} filename={item.filename} />
      </Attachments.Item>
    ))}
  </Attachments.Root>
)}
```

With a drop zone and a picker trigger:

```tsx
<>
  <Attachments.Dropzone visible={isDragging} portalSelector="#app-shell" />
  <Attachments.Root>
    {items.map((item) => (
      <Attachments.Item key={item.id}>
        <span>{item.filename}</span>
        <Attachments.Remove onRemove={() => remove(item.id)} filename={item.filename} />
      </Attachments.Item>
    ))}
  </Attachments.Root>
  <Attachments.Trigger onClick={openFileDialog} />
</>
```

## Accessibility

`Attachments.Remove` and `Attachments.Trigger` are real buttons with default
overridable names ("Remove attachment" / "Add attachment"). Pass `filename` to
`Remove` so each chip announces distinctly ("Remove report.pdf") — without it,
N remove buttons all read the same. `Attachments.Error` is a `role="alert"`
live region: content appearing inside it announces immediately (the copy is
still yours).

## API reference

Every part accepts `className`, `style`, and `render` (see
[Styling](/handbook/styling)) and emits a bespoke part attribute (`data-<part>`) unless noted.

### Attachments

The tray container. Renders `data-attachments` and takes no props of its own —
mount it conditionally when there are items to show.

### Attachments.Item

One attachment chip — a structural slot with no props of its own. Render the
filename, size, thumbnail or icon inside it however you like. Renders
`data-attachments-item`.

export const itemAttrs = [
  { attribute: "data-attachments-item", description: "The chip." },
];

<AttributesTable rows={itemAttrs} />

### Attachments.Remove

Removal affordance, shown on hover. Renders `<button data-attachments-remove>`
named "Remove attachment", or `Remove {filename}` when `filename` is set.

export const removeProps = [
  { name: "onRemove", type: "() => void", default: "(required)", description: "Called when the button is clicked." },
  { name: "filename", type: "string", description: "Interpolated into the accessible name so each chip's remove button announces distinctly." },
];

<PropsTable rows={removeProps} />

### Attachments.Dropzone

Drop overlay. Renders `data-attachments-dropzone`. Pass `portalSelector` to
portal it elsewhere — into the app shell, for instance, so files can be dropped
anywhere.

export const dropzoneProps = [
  { name: "visible", type: "boolean", default: "false", description: "Show the overlay (while files are dragged over the scope)." },
  { name: "keepMounted", type: "boolean", default: "false", description: "Keep it mounted (hidden) when not visible." },
  { name: "portalSelector", type: "string", description: "CSS selector to portal into (the mechanism behind global)." },
];

<PropsTable rows={dropzoneProps} />

export const dropzoneAttrs = [
  { attribute: "data-attachments-dropzone", description: "The overlay." },
  { attribute: "data-visible", description: "Present while visible is true." },
];

<AttributesTable rows={dropzoneAttrs} />

### Attachments.Error

Validation message slot. Renders `<span role="alert" data-attachments-error>`,
so content appearing inside it announces immediately. Validation emits a
structured `AttachmentErrorCode` (`"accept"`, `"max_file_size"`, `"max_files"`)
on the composer's attachments state — you map codes to your own (localized)
copy; the package ships no messages.

### Attachments.Trigger

The file-picker button. Renders `<button data-attachments-trigger>` named
"Add attachment" by default.

## Utilities

`@intentface/chat/attachments` exports the generic mechanics:
`toAttachmentItem` (the default blob ingestion), `matchesAccept`, and
`revokeAttachmentUrl`.

Everything above that is yours: the accept and size policy, the media taxonomy
that decides what an image or a PDF looks like, and the adapter that turns
submitted items into whatever your transport expects — AI SDK file parts, signed
uploads, or anything else. The package imposes none of it.
