---
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 { IconFile, IconX } from "@tabler/icons-react";
import { 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]"
        >
          <IconFile className="size-4 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]"
          >
            <IconX className="size-3.5" />
          </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`;
};
```

## 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} />
</>
```

## Examples

### Dropping, picking and rejecting files

The whole intake path in one tray. The package ships the mechanics —
`matchesAccept` and `toAttachmentItem` are plain functions — and none of the
policy: what counts as too large, and what the message says, are yours.

Validation emits a code rather than copy, which is why the wording lives in one
map in the demo and can be localised there.

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

import {
  type AttachmentErrorCode,
  type AttachmentItem,
  Attachments,
  matchesAccept,
  revokeAttachmentUrl,
  toAttachmentItem,
} from "@intentface/chat/attachments";
import { IconX } from "@tabler/icons-react";
import { type DragEvent, useEffect, useRef, useState } from "react";

const ACCEPT = "image/*,.pdf";
const MAX_BYTES = 2 * 1024 * 1024;

/*
 * The whole intake path: drop a file on the panel, or pick one with the
 * button, and watch a rejected file land in the error slot.
 *
 * The package ships the mechanics and none of the policy. `matchesAccept` and
 * `toAttachmentItem` are plain functions you call where you like; what counts
 * as too large, and what the message says when something is rejected, are
 * decided here. Validation emits a *code*, never copy, so the wording below is
 * ours to localise.
 */
export const Dropzone = () => {
  const [items, setItems] = useState<AttachmentItem[]>([]);
  const [dragging, setDragging] = useState(false);
  const [error, setError] = useState<AttachmentErrorCode | null>(null);
  const input = useRef<HTMLInputElement>(null);
  const depth = useRef(0);

  // Blob URLs outlive the component unless something revokes them.
  useEffect(() => () => items.forEach(revokeAttachmentUrl), [items]);

  const add = (files: FileList | null) => {
    if (!files?.length) return;
    setError(null);

    for (const file of Array.from(files)) {
      if (!matchesAccept(file, ACCEPT)) return setError("accept");
      if (file.size > MAX_BYTES) return setError("max_file_size");
      setItems((current) => [...current, toAttachmentItem(file)]);
    }
  };

  // dragenter/dragleave fire for every child element, so a bare boolean
  // flickers as the pointer crosses the tray. Counting depth does not.
  const onDragEnter = (event: DragEvent) => {
    event.preventDefault();
    depth.current += 1;
    setDragging(true);
  };
  const onDragLeave = () => {
    depth.current -= 1;
    if (depth.current <= 0) setDragging(false);
  };
  const onDrop = (event: DragEvent) => {
    event.preventDefault();
    depth.current = 0;
    setDragging(false);
    add(event.dataTransfer.files);
  };

  // A drop target is a pointer-only convenience, not a control. Giving it a role
  // would announce an affordance no keyboard user can reach; the picker button
  // below is the accessible route to the same thing.
  return (
    // biome-ignore lint/a11y/noStaticElementInteractions: drop target, not a control
    <div
      onDragEnter={onDragEnter}
      onDragOver={(event) => event.preventDefault()}
      onDragLeave={onDragLeave}
      onDrop={onDrop}
      className="relative flex w-full max-w-lg flex-col gap-3 rounded-xl border border-[#f0f0f0] bg-white p-3 dark:border-[#262626] dark:bg-[#181818]"
    >
      <Attachments.Dropzone
        visible={dragging}
        className="pointer-events-none absolute inset-0 z-10 hidden place-items-center rounded-xl border-2 border-[#1a1a1a] border-dashed bg-white/80 font-medium text-[#1a1a1a] text-sm data-[visible]:grid dark:border-[#fcfcfc] dark:bg-[#181818]/80 dark:text-[#fcfcfc]"
      >
        Drop to attach
      </Attachments.Dropzone>

      {items.length > 0 && (
        <Attachments.Root className="flex flex-wrap gap-2">
          {items.map((item) => (
            <Attachments.Item
              key={item.id}
              className="group/item flex h-8 items-center gap-2 rounded-lg border border-[#f0f0f0] bg-[#fafafa] pr-1 pl-2.5 text-[#1a1a1a] text-xs dark:border-[#2d2d2d] dark:bg-[#232323] dark:text-[#fcfcfc]"
            >
              <span className="max-w-40 truncate">{item.filename}</span>
              <Attachments.Remove
                filename={item.filename}
                onRemove={() => {
                  revokeAttachmentUrl(item);
                  setItems((current) => current.filter((candidate) => candidate.id !== item.id));
                }}
                className="grid size-5 cursor-pointer place-items-center rounded text-[#949494] opacity-0 transition-opacity hover:text-[#1a1a1a] group-hover/item:opacity-100 dark:text-[#6f6f6f] dark:hover:text-[#fcfcfc]"
              >
                <IconX className="size-3.5" />
              </Attachments.Remove>
            </Attachments.Item>
          ))}
        </Attachments.Root>
      )}

      <div className="flex items-center gap-3">
        <Attachments.Trigger
          onClick={() => input.current?.click()}
          className="h-8 cursor-pointer rounded-full border border-[#e4e4e4] bg-white px-4 font-medium text-[#1a1a1a] text-sm transition-colors hover:bg-[#f4f4f4] dark:border-[#2d2d2d] dark:bg-[#181818] dark:text-[#fcfcfc] dark:hover:bg-[#232323]"
        >
          Add attachment
        </Attachments.Trigger>

        {/* The package owns no file input; this one is ours. */}
        <input
          ref={input}
          type="file"
          multiple
          accept={ACCEPT}
          onChange={(event) => {
            add(event.target.files);
            event.target.value = "";
          }}
          className="hidden"
        />

        <span className="text-[#949494] text-xs dark:text-[#6f6f6f]">
          Images and PDFs, up to 2 MB. Try a .txt to see a rejection.
        </span>
      </div>

      {/* A live region: whatever appears inside announces immediately. */}
      <Attachments.Error className="text-red-600 text-xs empty:hidden dark:text-red-400">
        {error === null ? null : MESSAGES[error]}
      </Attachments.Error>
    </div>
  );
};

/** Codes in, copy out — the only place wording lives. */
const MESSAGES: Record<AttachmentErrorCode, string> = {
  accept: "That file type is not accepted. Images and PDFs only.",
  max_file_size: "That file is larger than 2 MB.",
  max_files: "Too many files at once.",
};
```

## 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. Mount it only when there are items to show; it renders no
layout of its own. Renders a `<div>` element.

### Attachments.Item

One attachment, as a structural slot with no media taxonomy of its own. Read
the item's `mediaType` and decide what an image or a PDF looks like. Renders a
`<div>` element.

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

<AttributesTable rows={itemAttrs} />

### Attachments.Remove

The removal affordance. Named "Remove attachment" by default, or
`Remove {filename}` when `filename` is set, so a row of them does not announce
identically. Renders a `<button>` element.

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

The drop overlay. `portalSelector` moves it elsewhere in the document, so files
can be dropped anywhere rather than only over the tray. Renders a `<div>`
element.

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

The validation slot, as a live region: whatever appears inside announces
immediately. Validation emits an `AttachmentErrorCode` — `"accept"`,
`"max_file_size"` or `"max_files"` — never copy, so the message is yours to
write and localise. Renders a `<span>` element with `role="alert"`.

### Attachments.Trigger

The file-picker button, named "Add attachment" by default. The package owns no
file input; wire this to your own. Renders a `<button>` element.

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