# Attachment

Show a file or an image attached to a message or a draft, with its upload state.

Images and files attached to a draft

[Complete TSX file](/examples/attachment/default.tsx)

```tsx
"use client";

import { XIcon } from "lucide-react";
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
} from "@/siteplane/components/attachment";
import { FileTypeIcon } from "@/siteplane/components/file-type-icon";
import { Spinner } from "@/siteplane/components/spinner";

/** A small chart drawn in place, so the example loads no image. */
function thumbnail(from: string, to: string, line: string) {
  return `data:image/svg+xml,${encodeURIComponent(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="96" height="96" fill="url(#g)"/><path d="M8 72 28 52 44 62 64 34 88 22" fill="none" stroke="${line}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
  )}`;
}

const images = [
  {
    meta: "PNG · 820 KB",
    name: "pipeline-overview.png",
    src: thumbnail("#dbeafe", "#93c5fd", "#1d4ed8"),
  },
  {
    meta: "PNG · 1.1 MB",
    name: "retention-curve.png",
    src: thumbnail("#dcfce7", "#86efac", "#15803d"),
  },
  {
    meta: "PNG · 940 KB",
    name: "weekly-signups.png",
    src: thumbnail("#fef3c7", "#fcd34d", "#b45309"),
  },
];

export default function Example() {
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <AttachmentGroup label="Images">
        {images.map((image) => (
          <Attachment key={image.name} orientation="vertical">
            <AttachmentMedia variant="image">
              <img alt="" src={image.src} />
            </AttachmentMedia>
            <AttachmentContent>
              <AttachmentTitle>{image.name}</AttachmentTitle>
              <AttachmentDescription>{image.meta}</AttachmentDescription>
            </AttachmentContent>
            <AttachmentActions>
              <AttachmentAction aria-label={`Remove ${image.name}`}>
                <XIcon />
              </AttachmentAction>
            </AttachmentActions>
          </Attachment>
        ))}
      </AttachmentGroup>
      <Attachment className="w-full" state="uploading">
        <AttachmentMedia>
          <Spinner aria-hidden="true" />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>q3-churn-review.pdf</AttachmentTitle>
          <AttachmentDescription>Uploading · 64%</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Cancel the upload of q3-churn-review.pdf">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment className="w-full">
        <AttachmentMedia>
          <FileTypeIcon name="renewal-terms.docx" size={32} />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>renewal-terms.docx</AttachmentTitle>
          <AttachmentDescription>DOCX · 48 KB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Remove renewal-terms.docx">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
    </div>
  );
}
```

## Installation

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/attachment
```

With npm: `npx shadcn@4.18.0 add @siteplane/attachment`. [Registry JSON](/registry/attachment.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/bubble @siteplane/dialog @siteplane/file-type-icon @siteplane/message @siteplane/prompt-input @siteplane/spinner
```

## Usage

```tsx
import { XIcon } from "lucide-react";
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/siteplane/components/attachment";
import { FileTypeIcon } from "@/siteplane/components/file-type-icon";

<Attachment>
  <AttachmentMedia>
    <FileTypeIcon name="q3-review.pdf" size={32} />
  </AttachmentMedia>
  <AttachmentContent>
    <AttachmentTitle>q3-review.pdf</AttachmentTitle>
    <AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
  </AttachmentContent>
  <AttachmentActions>
    <AttachmentAction aria-label="Remove q3-review.pdf">
      <XIcon />
    </AttachmentAction>
  </AttachmentActions>
</Attachment>;
```

The card shows the state it is given. The app owns the files and their uploads.

## Composition

```text
AttachmentGroup                  optional, a row of cards that scrolls
└── Attachment
    ├── AttachmentMedia          an icon, a File Type Icon or an image
    ├── AttachmentContent
    │   ├── AttachmentTitle
    │   └── AttachmentDescription
    ├── AttachmentActions
    │   └── AttachmentAction     remove, retry, cancel or download
    └── AttachmentTrigger        optional, over the whole card
```

## States

`state` follows a file through its upload. `idle` waits to upload and draws a dashed edge. While the file is `uploading` or `processing`, its title shimmers and the card is `aria-busy`. `error` draws the destructive edge and turns the description and the media tile to the destructive color. `done`, the default, is a file that is there.

Say the state in the description as well, such as “Uploading · 64%” or “Upload failed”, because color and motion alone do not reach everyone. Give a failed card a Retry action and an uploading card an action that cancels it.

A file in each upload state

[Complete TSX file](/examples/attachment/states.tsx)

```tsx
"use client";

import { ClockIcon, FileWarningIcon, RefreshCwIcon, XIcon } from "lucide-react";
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/siteplane/components/attachment";
import { FileTypeIcon } from "@/siteplane/components/file-type-icon";
import { Spinner } from "@/siteplane/components/spinner";

export default function Example() {
  return (
    <div className="grid w-full max-w-md gap-2">
      <Attachment className="w-full" state="idle">
        <AttachmentMedia>
          <ClockIcon />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>renewal-terms.docx</AttachmentTitle>
          <AttachmentDescription>Ready to upload</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Remove renewal-terms.docx">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment className="w-full" state="uploading">
        <AttachmentMedia>
          <Spinner aria-hidden="true" />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>customer-export.zip</AttachmentTitle>
          <AttachmentDescription>Uploading · 64%</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Cancel the upload of customer-export.zip">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment className="w-full" state="processing">
        <AttachmentMedia>
          <FileTypeIcon name="market-research.pdf" size={32} />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>market-research.pdf</AttachmentTitle>
          <AttachmentDescription>Reading the document</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Remove market-research.pdf">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment className="w-full" state="error">
        <AttachmentMedia>
          <FileWarningIcon />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>renewals-pipeline.xlsx</AttachmentTitle>
          <AttachmentDescription>Upload failed</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Retry the upload of renewals-pipeline.xlsx">
            <RefreshCwIcon />
          </AttachmentAction>
          <AttachmentAction aria-label="Remove renewals-pipeline.xlsx">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment className="w-full">
        <AttachmentMedia>
          <FileTypeIcon name="usage-report.csv" size={32} />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>usage-report.csv</AttachmentTitle>
          <AttachmentDescription>CSV · 1.8 MB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Remove usage-report.csv">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
    </div>
  );
}
```

## Media

`AttachmentMedia` holds the picture of the file. With `variant="icon"`, the default, it is a square tile for a lucide icon or a Spinner. A [File Type Icon](/docs/components/file-type-icon/) sits in it without the tile: `size={32}` fits a medium card and `size={24}` a small one. With `variant="image"`, an `img` fills it as a thumbnail, cropped to the square. A thumbnail shows dimmed while its file uploads or is processed.

## Sizes

`size="md"`, the default, has a 40 px media tile and 14 px text. `size="sm"` has a 32 px tile and 12 px text, for cards inside a field such as the Prompt Input. A name that does not fit ends in an ellipsis and keeps its full text in a `title` attribute, so the actions stay in the card.

Medium and small cards with a long name

[Complete TSX file](/examples/attachment/sizes.tsx)

```tsx
"use client";

import { XIcon } from "lucide-react";
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/siteplane/components/attachment";
import { FileTypeIcon } from "@/siteplane/components/file-type-icon";

const name = "onboarding-notes-for-the-enterprise-accounts-team.docx";

export default function Example() {
  return (
    <div className="grid w-full max-w-md gap-3">
      <Attachment className="w-full" size="md">
        <AttachmentMedia>
          <FileTypeIcon name={name} size={32} />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>{name}</AttachmentTitle>
          <AttachmentDescription>DOCX · 48 KB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label={`Remove ${name}`}>
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment className="w-full" size="sm">
        <AttachmentMedia>
          <FileTypeIcon name={name} size={24} />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>{name}</AttachmentTitle>
          <AttachmentDescription>DOCX · 48 KB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label={`Remove ${name}`}>
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
    </div>
  );
}
```

## Orientation

`orientation="horizontal"`, the default, puts the media beside the text. `orientation="vertical"` stacks the media over the text, for images. The card is then 120 px wide with text and 96 px with the media alone, and its actions float over the top-end corner of the image as the opaque default Button, which reads on any picture. The first example on this page shows vertical cards with text, and the [message](#in-a-message) example one with the image alone.

## Actions

`AttachmentAction` is an icon Button in `AttachmentActions` for an action such as remove, retry, cancel or download. Beside the text it is the bare muted action in a 24 px target. It has no text, so its `aria-label` names the action and the file, such as “Remove contracts.pdf”. Button props pass through: a `variant` replaces the default look, and `render` makes the action a download link.

## Trigger

`AttachmentTrigger` lies over the whole card, behind the actions, and opens the file or a preview of it. Put it in the `render` of a DialogTrigger for a preview, as here, or give it `render` for a link. It has no text, so its `aria-label` says what it does, such as “Preview pipeline-overview.png”. The actions above it stay clickable.

An image card that opens a preview

[Complete TSX file](/examples/attachment/trigger.tsx)

```tsx
"use client";

import { XIcon } from "lucide-react";
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
  AttachmentTrigger,
} from "@/siteplane/components/attachment";
import { Button } from "@/siteplane/components/button";
import {
  Dialog,
  DialogClose,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogPanel,
  DialogPopup,
  DialogTitle,
  DialogTrigger,
} from "@/siteplane/components/dialog";

/** A small chart drawn in place, so the example loads no image. */
const chart = `data:image/svg+xml,${encodeURIComponent(
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#dbeafe"/><stop offset="1" stop-color="#93c5fd"/></linearGradient></defs><rect width="96" height="96" fill="url(#g)"/><path d="M8 72 28 52 44 62 64 34 88 22" fill="none" stroke="#1d4ed8" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
)}`;

export default function Example() {
  return (
    <Dialog>
      <Attachment orientation="vertical">
        <AttachmentMedia variant="image">
          <img alt="" src={chart} />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>pipeline-overview.png</AttachmentTitle>
          <AttachmentDescription>PNG · 820 KB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Remove pipeline-overview.png">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
        {/* The trigger fills the card, behind the action. */}
        <DialogTrigger
          render={
            <AttachmentTrigger aria-label="Preview pipeline-overview.png" />
          }
        />
      </Attachment>
      <DialogPopup showCloseButton={false}>
        <DialogHeader>
          <DialogTitle>pipeline-overview.png</DialogTitle>
          <DialogDescription>PNG · 820 KB</DialogDescription>
        </DialogHeader>
        <DialogPanel>
          <img
            alt="A line chart of the pipeline that rises over the quarter"
            className="w-full"
            src={chart}
          />
        </DialogPanel>
        <DialogFooter>
          <DialogClose render={<Button variant="outline" />}>Close</DialogClose>
        </DialogFooter>
      </DialogPopup>
    </Dialog>
  );
}
```

## Group

`AttachmentGroup` is a row of cards that scrolls sideways, snaps card by card and fades the edge that has more to show. Cards that join or leave fade, and the others glide to their new place. Give every Attachment a stable `key`, such as an id and never the index, because the key tells the row which card has left. Name the row with `label`, such as “Attachments”, so it is a labeled group. In a group a card is at most 16 rem wide, so a long name ends in an ellipsis there too; a `max-w` class on the card widens it.

Files in a row that scrolls

[Complete TSX file](/examples/attachment/group.tsx)

```tsx
"use client";

import { XIcon } from "lucide-react";
import { useState } from "react";
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
} from "@/siteplane/components/attachment";
import { Button } from "@/siteplane/components/button";
import { FileTypeIcon } from "@/siteplane/components/file-type-icon";

const allFiles = [
  { meta: "PDF · 2.4 MB", name: "q3-churn-review.pdf" },
  { meta: "CSV · 310 KB", name: "usage-q3.csv" },
  { meta: "DOCX · 48 KB", name: "renewal-terms.docx" },
  { meta: "XLSX · 120 KB", name: "pricing-2027.xlsx" },
  { meta: "ZIP · 18 MB", name: "support-export.zip" },
];

export default function Example() {
  const [files, setFiles] = useState(allFiles);

  return (
    <div className="grid w-full max-w-md gap-3">
      <AttachmentGroup label="Attachments">
        {files.map((file) => (
          <Attachment key={file.name}>
            <AttachmentMedia>
              <FileTypeIcon name={file.name} size={32} />
            </AttachmentMedia>
            <AttachmentContent>
              <AttachmentTitle>{file.name}</AttachmentTitle>
              <AttachmentDescription>{file.meta}</AttachmentDescription>
            </AttachmentContent>
            <AttachmentActions>
              <AttachmentAction
                aria-label={`Remove ${file.name}`}
                onClick={() =>
                  setFiles((current) =>
                    current.filter((entry) => entry.name !== file.name),
                  )
                }
              >
                <XIcon />
              </AttachmentAction>
            </AttachmentActions>
          </Attachment>
        ))}
      </AttachmentGroup>
      <Button
        className="justify-self-start"
        disabled={files.length === allFiles.length}
        onClick={() => setFiles(allFiles)}
        size="sm"
        variant="outline"
      >
        Restore files
      </Button>
    </div>
  );
}
```

A card is inert while it leaves, so its button cannot keep focus. Move focus before you remove a card, for example to the message field.

## In a message

A sent turn shows its files above its text. Put an `AttachmentGroup` in the content of a user turn, above its Bubble. In a [Message](/docs/components/message/) with `align="end"` the group is only as wide as its cards, so they sit at the end of the turn like the bubble, and a long row still scrolls. A sent file needs no remove action; a trigger can open it.

A user message with an image and a PDF

[Complete TSX file](/examples/attachment/message.tsx)

```tsx
"use client";

import {
  Attachment,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
} from "@/siteplane/components/attachment";
import { Bubble, BubbleContent } from "@/siteplane/components/bubble";
import { FileTypeIcon } from "@/siteplane/components/file-type-icon";
import { Message, MessageContent } from "@/siteplane/components/message";

/** A small chart drawn in place, so the example loads no image. */
const chart = `data:image/svg+xml,${encodeURIComponent(
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#dcfce7"/><stop offset="1" stop-color="#86efac"/></linearGradient></defs><rect width="96" height="96" fill="url(#g)"/><path d="M8 72 28 52 44 62 64 34 88 22" fill="none" stroke="#15803d" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
)}`;

export default function Example() {
  return (
    <div className="w-full max-w-md">
      <Message align="end">
        <MessageContent>
          <AttachmentGroup label="Attachments">
            <Attachment key="retention-curve" orientation="vertical">
              <AttachmentMedia variant="image">
                <img alt="retention-curve.png" src={chart} />
              </AttachmentMedia>
            </Attachment>
            <Attachment key="q3-churn-review">
              <AttachmentMedia>
                <FileTypeIcon name="q3-churn-review.pdf" size={32} />
              </AttachmentMedia>
              <AttachmentContent>
                <AttachmentTitle>q3-churn-review.pdf</AttachmentTitle>
                <AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
              </AttachmentContent>
            </Attachment>
          </AttachmentGroup>
          <Bubble align="end" variant="muted">
            <BubbleContent>
              Which of these accounts lost the most seats since June?
            </BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </div>
  );
}
```

## In the composer

In a [Prompt Input](/docs/components/prompt-input/#attachments), the group goes in `PromptInputAttachments`, the row above the field, with small cards. That page also shows files that upload and go with the message.

Small cards in the message field

[Complete TSX file](/examples/attachment/composer.tsx)

```tsx
"use client";

import { RefreshCwIcon, XIcon } from "lucide-react";
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
} from "@/siteplane/components/attachment";
import { FileTypeIcon } from "@/siteplane/components/file-type-icon";
import {
  PromptInput,
  PromptInputAttachments,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputToolbar,
  PromptInputToolbarGroup,
} from "@/siteplane/components/prompt-input";

/** A small chart drawn in place, so the example loads no image. */
const chart = `data:image/svg+xml,${encodeURIComponent(
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#dbeafe"/><stop offset="1" stop-color="#93c5fd"/></linearGradient></defs><rect width="96" height="96" fill="url(#g)"/><path d="M8 72 28 52 44 62 64 34 88 22" fill="none" stroke="#1d4ed8" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
)}`;

export default function Example() {
  return (
    <div className="w-full max-w-lg">
      <PromptInput onSubmit={() => undefined}>
        <PromptInputAttachments>
          <AttachmentGroup label="Attachments">
            <Attachment key="usage" size="sm" state="uploading">
              <AttachmentMedia>
                <FileTypeIcon name="usage-q3.csv" size={24} />
              </AttachmentMedia>
              <AttachmentContent>
                <AttachmentTitle>usage-q3.csv</AttachmentTitle>
                <AttachmentDescription>Uploading · 64%</AttachmentDescription>
              </AttachmentContent>
              <AttachmentActions>
                <AttachmentAction aria-label="Cancel the upload of usage-q3.csv">
                  <XIcon />
                </AttachmentAction>
              </AttachmentActions>
            </Attachment>
            <Attachment key="contracts" size="sm" state="error">
              <AttachmentMedia>
                <FileTypeIcon name="contracts.pdf" size={24} />
              </AttachmentMedia>
              <AttachmentContent>
                <AttachmentTitle>contracts.pdf</AttachmentTitle>
                <AttachmentDescription>Upload failed</AttachmentDescription>
              </AttachmentContent>
              <AttachmentActions>
                <AttachmentAction aria-label="Retry the upload of contracts.pdf">
                  <RefreshCwIcon />
                </AttachmentAction>
                <AttachmentAction aria-label="Remove contracts.pdf">
                  <XIcon />
                </AttachmentAction>
              </AttachmentActions>
            </Attachment>
            <Attachment key="pipeline" size="sm">
              <AttachmentMedia variant="image">
                <img alt="" src={chart} />
              </AttachmentMedia>
              <AttachmentContent>
                <AttachmentTitle>pipeline-overview.png</AttachmentTitle>
                <AttachmentDescription>PNG · 820 KB</AttachmentDescription>
              </AttachmentContent>
              <AttachmentActions>
                <AttachmentAction aria-label="Remove pipeline-overview.png">
                  <XIcon />
                </AttachmentAction>
              </AttachmentActions>
            </Attachment>
          </AttachmentGroup>
        </PromptInputAttachments>
        <PromptInputTextarea placeholder="Ask about these files" />
        <PromptInputToolbar>
          <PromptInputToolbarGroup align="end">
            <PromptInputSubmit />
          </PromptInputToolbarGroup>
        </PromptInputToolbar>
      </PromptInput>
    </div>
  );
}
```

## AI SDK

A `file` part of a `UIMessage` from `useChat` is an Attachment. The examples on this page run on local state; the code below renders the parts of a message instead.

### File parts

A part whose media type is `image/*` shows its `url` as a thumbnail, and any other part shows a File Type Icon chosen by its `filename`. A sent message shows its files in a group above its text.

```tsx
import type { FileUIPart, UIMessage } from "ai";
import {
  Attachment,
  AttachmentContent,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
} from "@/siteplane/components/attachment";
import { FileTypeIcon } from "@/siteplane/components/file-type-icon";

function FileAttachment({ part }: { part: FileUIPart }) {
  const name = part.filename ?? "Attachment";
  if (part.mediaType.startsWith("image/"))
    return (
      <Attachment orientation="vertical">
        <AttachmentMedia variant="image">
          <img alt={name} src={part.url} />
        </AttachmentMedia>
      </Attachment>
    );
  return (
    <Attachment>
      <AttachmentMedia>
        <FileTypeIcon name={name} size={32} />
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>{name}</AttachmentTitle>
      </AttachmentContent>
    </Attachment>
  );
}

// In the MessageContent of a user turn, above its Bubble:
export function MessageFiles({ message }: { message: UIMessage }) {
  const files = message.parts.filter(
    (part): part is FileUIPart => part.type === "file",
  );
  if (files.length === 0) return null;
  return (
    <AttachmentGroup label="Attachments">
      {files.map((part, index) => (
        <FileAttachment key={`${message.id}-${index}`} part={part} />
      ))}
    </AttachmentGroup>
  );
}
```

### Upload states

A file part has no upload state, so a sent file is `done`, the default. The states belong to the draft while your app uploads its files: a card is `uploading` until the file has its URL, and `error` when the upload fails. The [Prompt Input](/docs/components/prompt-input/#files) page turns picked files into `FileUIPart`s and sends them with `sendMessage`.

## API reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| Attachment.state | idle \| uploading \| processing \| error \| done | done | Where the file is in its upload. uploading and processing shimmer the title and set aria-busy, error draws the destructive edge, and idle draws a dashed one. |
| Attachment.size | sm \| md | md | md has a 40 px media tile and 14 px text; sm has a 32 px tile and 12 px text, for cards inside a field. |
| Attachment.orientation | horizontal \| vertical | horizontal | vertical stacks the media over the text, for images. The card is 120 px wide with text and 96 px with the media alone. |
| AttachmentMedia.variant | icon \| image | icon | icon is a square tile for an icon, and shows a File Type Icon without the tile; image holds an img that fills it. |
| AttachmentAction.aria-label | string | required | Names the action and its file, such as Remove brief.pdf. |
| AttachmentAction | Button props | muted inline icon Button | Beside the text the bare muted action in a 24 px target, over the image of a vertical card the opaque default Button. Button props replace the look. |
| AttachmentTrigger.aria-label | string | required | Says what activating the card does, such as Open brief.pdf. |
| AttachmentTrigger.render | ReactElement \| render function | button | Renders the trigger as another element, such as a link. In the render of a DialogTrigger it opens the dialog. |
| AttachmentGroup.label | string | unset | Names the row for assistive technology and makes it a group. A row that scrolls should have one. |
Attachment, AttachmentMedia, AttachmentContent, AttachmentActions and
AttachmentGroup accept the native props of a div, and AttachmentTitle and
AttachmentDescription those of a span. A title or description that is a string
is also the element’s `title` attribute, so a name cut off by its ellipsis can
still be read in full. AttachmentAction and AttachmentTrigger accept the Button
props, with `aria-label` required. AttachmentGroup lays its cards out in a
Scroll Area with an edge fade and no scrollbar, as a Motion list. The types
AttachmentProps, AttachmentState, AttachmentSize, AttachmentOrientation,
AttachmentMediaProps, AttachmentActionProps, AttachmentTriggerProps and
AttachmentGroupProps are exported.

[Read the installed source](/source/attachment).

## Usage notes

The card uploads nothing and reads no file. The app starts, retries and cancels the uploads and gives each card its state and its details in words. A File Type Icon takes the type from the file name only, so pass the thumbnail of an image yourself.

While the row of a group overflows, its scroll area joins the tab order and scrolls with the arrow keys. Under reduced motion the shimmer of a title stands still, and cards join and leave a group at once.
