# Attachment

> File and image attachment chips with thumbnails, upload progress rings, error/retry, remove, a “+N” overflow stack and a dropzone.

- **Slug:** `attachment` · **Category:** input · **Status:** beta
- **Import:** `import { Attachment, AttachmentDropzone, AttachmentItem, AttachmentKind, AttachmentList, AttachmentProgressRing, AttachmentStatus } from "@kura-ui/react/attachment";`
- **Docs:** https://kuro.abstergo.fyi/components/attachment
- **Registry item:** https://kuro.abstergo.fyi/r/attachment.json
- **Tags:** file, upload, attachment, image, thumbnail, dropzone, progress, drag-and-drop

## Install

```bash
# shadcn registry (copies source into your project)
npx shadcn@latest add https://kuro.abstergo.fyi/r/attachment.json
# Kura CLI (same, plus dependency resolution and --json output)
npx @kura-ui/cli add attachment
# npm package
npm install @kura-ui/react
```

## When to use

- Show files the user attached to a prompt before sending (inside or above a composer).
- Display upload progress, failures and a retry action for files sent to a model.
- Render the files that belong to a sent message or an agent artifact list.
- A standalone drop area for documents to index, e.g. “Add files to this project”.

## When not to use

- A complete prompt input with paste and drag-drop built in — use composer (it renders attachment for you).
- Rich previews of generated documents or code — use artifact-card or code-block.
- Audio recordings with playback — use audio-waveform with `peaks`.

## Props

| Prop | Type | Default | Required | Description |
| --- | --- | --- | --- | --- |
| `name` | `string` | — | yes | File name shown on the chip; long names truncate with an ellipsis. |
| `size` | `number` | — | no | Size in bytes, rendered as “2.4 MB”. |
| `type` | `string` | — | no | MIME type. Chooses the file-type icon (image, pdf, code, text, sheet, audio, video, archive) and whether a preview renders as a thumbnail. |
| `previewUrl` | `string` | — | no | Image URL (object URL or remote). With an image type the attachment renders as a 44px square thumbnail. |
| `status` | `"ready" \| "uploading" \| "error"` | `"ready"` | no | Upload state. Uploading shows a progress ring; error shows a danger tint. |
| `progress` | `number` | — | no | Upload progress 0..1 while uploading. Omit for an indeterminate spinning arc. |
| `error` | `string` | — | no | Error message for status=error; shown as the title and announced via role=alert. |
| `variant` | `"auto" \| "chip" \| "tile"` | `"auto"` | no | auto = thumbnail tile for images that have a previewUrl, a chip (icon + name + size) otherwise. |
| `onRemove` | `() => void` | — | no | Render a × button (revealed on hover/focus, always visible on touch). |
| `onRetry` | `() => void` | — | no | Render a Retry action while status is error. |

Native attributes of the root element, `className` and `ref` are passed through.

## Anatomy

- **Attachment** (`data-slot="attachment"`) — One file. Exposes data-variant (chip|tile), data-kind and data-status (ready|uploading|error).
- **AttachmentRemove** (`data-slot="attachment-remove"`) — The × button, labelled “Remove <name>”. Rendered when onRemove is set.
- **AttachmentList** (`data-slot="attachment-list"`) — Wrapping <ul> of attachments with pop-in/out and gap-closing layout motion, plus the “+N” stack.
  - `items`: `readonly AttachmentItem[]` — Items: { id, name, size?, type?, url?, status?, progress?, error?, file? }.
  - `onRemove`: `(id: string) => void` — Remove handler.
  - `onRetry`: `(id: string) => void` — Retry handler.
  - `max`: `number` — Show at most this many items, then a “+N” stack button that reveals the rest.
  - `variant`: `"auto" | "chip" | "tile"` = `"auto"` — Forwarded to every item.
  - `label`: `string` = `"Attachments"` — Accessible name of the list.
- **AttachmentOverflow** (`data-slot="attachment-overflow"`) — The “+N” stack button (a counter with two offset cards behind it).
- **AttachmentDropzone** (`data-slot="attachment-dropzone"`) — Click-to-browse + drop target with a dashed hairline. data-dragging is set only while a drag carries files.
  - `onFiles`: `(files: File[], rejected: File[]) => void` — Accepted files, and those rejected by accept/maxSize.
  - `accept`: `string` — Like <input accept>, e.g. "image/*,.pdf".
  - `multiple`: `boolean` = `true` — Allow several files.
  - `maxSize`: `number` — Reject files larger than this (bytes).
  - `disabled`: `boolean` — Disable browsing and dropping.
  - `title`: `React.ReactNode` = `"Drop files or browse"` — Headline.
  - `description`: `React.ReactNode` — Hint under the headline.
- **AttachmentProgressRing** (`data-slot="attachment-progress"`) — Standalone circular progress (currentColor) used by uploading attachments.

## Accessibility

- Image tiles render <img alt={name}>; chips render the name as text, so every file has a readable name.
- The remove button is a real <button> labelled “Remove <file name>” with a 28px hit area; it is revealed on hover and on keyboard focus within the item.
- Upload failures are announced once with role=alert; progress is not announced on every tick.
- The list is a <ul aria-label="Attachments">; the overflow button is labelled “Show N more attachments”.
- The dropzone is a native <button> that opens the file picker; the drop target never steals focus.
- Reduced motion: Items fade in and out without scale, blur or layout travel; the indeterminate ring pulses instead of spinning; hover lifts are removed.

| Keys | Action |
| --- | --- |
| Tab | Moves through each attachment's Retry and Remove buttons |
| Enter / Space | Activates Remove, Retry, the +N stack or the dropzone |

## Notes for agents

- Keep attachments in state as AttachmentItem[] and render <AttachmentList items={…} />; use createAttachment(file) to build items (it creates an object URL for images — revoke it with URL.revokeObjectURL when removing).
- Helpers exported alongside: formatBytes, attachmentKind, acceptsFile, filesFromDataTransfer, dragHasFiles.
- Progress is 0..1, not a percentage. Omit progress for an indeterminate ring.
- Inside a composer, prefer composer's built-in ComposerAttachments, which wires paste, drop and removal.

## Examples

### A single file (minimal)

```tsx
import { Attachment } from "@kura-ui/react/attachment";

export function AttachmentMinimal() {
  return <Attachment name="q3-board-report.pdf" type="application/pdf" size={2_480_000} />;
}
```

### Mixed states with overflow (full)

Thumbnails, a live upload, a failed upload with retry, remove buttons and a +N stack.

```tsx
"use client";

import * as React from "react";
import { AttachmentList } from "@kura-ui/react/attachment";
import { type AttachmentItem } from "@kura-ui/react/attachment";

const initial: AttachmentItem[] = [
  { id: "1", name: "brief.md", type: "text/markdown", size: 4_210 },
  { id: "2", name: "harbour.png", type: "image/png", size: 812_000, url: photo(205, 160) },
  {
    id: "3",
    name: "transactions.csv",
    type: "text/csv",
    size: 18_400_000,
    status: "uploading",
    progress: 0.18,
  },
  {
    id: "4",
    name: "schema.ts",
    type: "text/typescript",
    size: 9_800,
    status: "error",
    error: "Network connection lost",
  },
  { id: "5", name: "dunes.jpg", type: "image/jpeg", size: 1_200_000, url: photo(30, 25) },
  { id: "6", name: "interview.m4a", type: "audio/mp4", size: 6_300_000 },
  { id: "7", name: "notes.zip", type: "application/zip", size: 340_000 },
];

const photo = (a: number, b: number) =>
  `data:image/svg+xml;utf8,${encodeURIComponent(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="hsl(${a} 55% 78%)"/><stop offset="1" stop-color="hsl(${a + 20} 45% 90%)"/></linearGradient></defs><rect width="64" height="64" fill="url(#g)"/><circle cx="44" cy="20" r="7" fill="hsl(${b} 80% 92%)"/><path d="M0 46 L18 30 L32 42 L44 34 L64 50 V64 H0Z" fill="hsl(${b} 25% 38%)"/><path d="M0 54 L22 44 L40 52 L64 46 V64 H0Z" fill="hsl(${b} 20% 26%)"/></svg>`,
  )}`;

export function AttachmentFull() {
  const [items, setItems] = React.useState(initial);

  // Simulate an upload ticking forward.
  React.useEffect(() => {
    const id = setInterval(() => {
      setItems((list) =>
        list.map((item) => {
          if (item.status !== "uploading") return item;
          const progress = Math.min(1, (item.progress ?? 0) + 0.07 + Math.random() * 0.06);
          return progress >= 1
            ? { ...item, status: "ready", progress: undefined }
            : { ...item, progress };
        }),
      );
    }, 450);
    return () => clearInterval(id);
  }, []);

  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <AttachmentList
        items={items}
        max={4}
        onRemove={(id) => setItems((list) => list.filter((i) => i.id !== id))}
        onRetry={(id) =>
          setItems((list) =>
            list.map((i) =>
              i.id === id ? { ...i, status: "uploading", progress: 0, error: undefined } : i,
            ),
          )
        }
      />
      <p className="text-kura-fg-subtle text-xs">
        Hover a file to remove it · Retry the failed upload · Open the +N stack
      </p>
    </div>
  );
}
```

### Dropzone feeding a list (composition)

Browse or drop files; accepted ones upload, rejected ones are reported.

```tsx
"use client";

import * as React from "react";
import { AttachmentDropzone, AttachmentList } from "@kura-ui/react/attachment";
import { type AttachmentItem, createAttachment } from "@kura-ui/react/attachment";

export function AttachmentWithDropzone() {
  const [items, setItems] = React.useState<AttachmentItem[]>([]);
  const [rejected, setRejected] = React.useState<string | null>(null);

  React.useEffect(() => {
    if (!items.some((i) => i.status === "uploading")) return;
    const id = setInterval(() => {
      setItems((list) =>
        list.map((i) => {
          if (i.status !== "uploading") return i;
          const progress = (i.progress ?? 0) + 0.2;
          return progress >= 1
            ? { ...i, status: "ready", progress: undefined }
            : { ...i, progress };
        }),
      );
    }, 300);
    return () => clearInterval(id);
  }, [items]);

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <AttachmentDropzone
        accept="image/*,.pdf,.csv,.md,.txt"
        maxSize={10 * 1024 * 1024}
        description="Images, PDF, CSV or Markdown · up to 10 MB"
        onFiles={(files, bad) => {
          setRejected(bad.length ? `${bad.map((f) => f.name).join(", ")} not supported` : null);
          setItems((list) => [
            ...list,
            ...files.map((f) => createAttachment(f, { status: "uploading", progress: 0 })),
          ]);
        }}
      />
      {rejected && (
        <p role="alert" className="text-kura-danger-text text-xs">
          {rejected}
        </p>
      )}
      {items.length > 0 && (
        <AttachmentList
          items={items}
          onRemove={(id) =>
            setItems((list) => {
              const gone = list.find((i) => i.id === id);
              if (gone?.url?.startsWith("blob:")) URL.revokeObjectURL(gone.url);
              return list.filter((i) => i.id !== id);
            })
          }
        />
      )}
    </div>
  );
}
```

## Dependencies

- npm: `lucide-react`, `motion`
- Kura registry items: `kura-theme`, `kura-motion`, `use-reduced-motion`, `utils`
- Related: `composer`, `artifact-card`, `audio-waveform`
