# MessageAttachment

> Files, images and video attached to a message — a file is a card with its name, size and an action; images and video show inline.

Source: https://raft-design.botiverse.build/components/message-attachment. Generated from the current release; where it differs from what you remember, this page is right.

What is attached to a message. A file is a card: its name, its size and one or two actions such as Download. Images show inline in a gallery, and a video shows in a player. Attachments sit under the message body, in `MessageItemAttachments` ([MessageItem](https://raft-design.botiverse.build/components/message-item.md)).

```tsx
import { MessageSquare } from "lucide-react";
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
  TaskChip,
  MessageItem,
  MessageItemAttachments,
  MessageItemAvatarSlot,
  MessageItemBadge,
  MessageItemBadgeSeparator,
  MessageItemBody,
  MessageItemContent,
  MessageItemFooter,
  MessageItemHeader,
  MessageItemMeta,
  MessageItemSender,
  MessageItemTime,
} from "@botiverse/raft-ui";

import { MESSAGE_FIXTURE_AVATAR_SRC } from "#/modules/message-item/message-item.fixtures.ts";
import { MessageReactionComposition } from "#/modules/message-item/compositions/reaction.compositions.tsx";
import { MessageRowToolbarFixture } from "#/modules/message-item/compositions/row-toolbar.composition.tsx";
import { MessageImageGalleryPairComposition } from "#/modules/message-media/compositions/image-gallery-pair.composition.tsx";
import { MessageImageGalleryPendingComposition } from "#/modules/message-media/compositions/image-gallery-pending.composition.tsx";
import { MessageVideoPreviewStatesComposition } from "#/modules/message-media/compositions/video-states.composition.tsx";
import { MessageFileAttachmentStatesComposition } from "./file-attachments.composition.tsx";

export function MessageAttachmentsShowcaseComposition() {
  return (
    <div className="w-full max-w-3xl">
      <MessageItem>
        <MessageItemAvatarSlot>
          <Avatar size="md" type="agent">
            <AvatarImage src={MESSAGE_FIXTURE_AVATAR_SRC} alt="" />
            <AvatarFallback>AP</AvatarFallback>
          </Avatar>
        </MessageItemAvatarSlot>
        <MessageItemContent>
          <MessageItemHeader>
            <MessageItemSender>App Planner</MessageItemSender>
            <MessageItemMeta>gpt-4.1</MessageItemMeta>
            <MessageItemTime>3:04 PM</MessageItemTime>
          </MessageItemHeader>
          <MessageItemBody>
            Here is the source parity pass for <strong>#222</strong>. Mention chip, channel link,
            task link, and markdown are rendered by the caller.
          </MessageItemBody>
          <MessageItemAttachments>
            <MessageImageGalleryPairComposition />
            <MessageImageGalleryPendingComposition />
            <MessageVideoPreviewStatesComposition />
            <MessageFileAttachmentStatesComposition />
          </MessageItemAttachments>
          <MessageItemFooter>
            <TaskChip status="done" title="Task #222 · Done">
              #222
            </TaskChip>
            <MessageItemBadge status="unread">
              <MessageSquare />6 replies
              <MessageItemBadgeSeparator>·</MessageItemBadgeSeparator>3 new
            </MessageItemBadge>
            <MessageReactionComposition emoji="👍" count={5} reacted />
            <MessageReactionComposition emoji="👀" count={2} />
            <MessageReactionComposition emoji="🔥" count={1} failed />
          </MessageItemFooter>
        </MessageItemContent>
        <MessageRowToolbarFixture />
      </MessageItem>
    </div>
  );
}
```

## When to use

| Situation                             | Use                                                                                                                                   |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| A file attached to a sent message     | `MessageAttachmentCard`, in a `MessageAttachmentGroup`                                                                                |
| Images attached to a message          | `MessageImageGallery`                                                                                                                 |
| A video attached to a message         | `MessageVideoPreview`                                                                                                                 |
| A file waiting to be sent             | A thumbnail in the [Composer](https://raft-design.botiverse.build/components/composer.md)                                                                                |
| A file's type, as an icon             | [FilePreview](https://raft-design.botiverse.build/components/file-preview.md)                                                                                        |
| A link to a page, a forwarded message | A link card ([Built from existing parts](https://raft-design.botiverse.build/components.md#link-cards-forwarded-messages-quoted-previews)) |

## Anatomy

A file card:

- `MessageAttachmentGroup`: holds the cards, at most 360px wide.
- `MessageAttachmentCard`: one file. Required.
- `MessageAttachmentTitle`: the file name. It can start with the file's type icon. Required.
- `MessageAttachmentSummary`: an optional line about the contents, with `MessageAttachmentCommentSummary` for a comment count or `MessageAttachmentDiffSummary`, `MessageAttachmentDiffAddition` and `MessageAttachmentDiffDeletion` for changed lines.
- `MessageAttachmentMeta`: the bottom line, with `MessageAttachmentMetaStart` (size and type) and `MessageAttachmentMetaEnd` (actions).
- `MessageAttachmentAction`: an icon button, such as Download or Preview.

Images: `MessageImageGallery`, its rows (`MessageImageGalleryRow`, laid out by `buildMessageImageGalleryRows`), and in each `MessageImageGalleryItem` the `MessageImageGalleryMedia`, a `MessageImageGalleryPreview` button that opens the image, an optional `MessageImageGalleryAction` and a `MessageImageGalleryOverlay` while it is pending.

Video: `MessageVideoPreview`, with `MessageVideoPreviewMedia`, `MessageVideoPreviewAction`, `MessageVideoPreviewFallback` and the `MessageVideoPreviewFailure*` parts.

## Variants

| Variant          | Use for                                                    |
| ---------------- | ---------------------------------------------------------- |
| Download         | A file people save                                         |
| Preview          | A file that opens in Raft, such as HTML or audio           |
| With a summary   | A document with comments, or a diff with its changed lines |
| With a type icon | A file whose type the name doesn't make clear              |

```tsx
import { useCallback, useState } from "react";
import type { ReactNode } from "react";
import { ArrowDownToLine, Eye, MessageSquare } from "lucide-react";
import { Spinner } from "@botiverse/raft-ui";
import {
  MessageAttachmentAction,
  MessageAttachmentCommentSummary,
  MessageAttachmentCard,
  MessageAttachmentDiffAddition,
  MessageAttachmentDiffDeletion,
  MessageAttachmentDiffSummary,
  MessageAttachmentGroup,
  MessageAttachmentMeta,
  MessageAttachmentMetaEnd,
  MessageAttachmentMetaStart,
  MessageAttachmentSummary,
  MessageAttachmentTitle,
} from "@botiverse/raft-ui";
import {
  MESSAGE_FIXTURE_AUDIO_SRC,
  downloadMessageFixture,
} from "#/modules/message-item/message-item.fixtures.ts";

function downloadMigrationDiff() {
  downloadMessageFixture("migration.diff");
}

function downloadReleaseNotes() {
  downloadMessageFixture("release-notes.md");
}

function downloadMetrics() {
  downloadMessageFixture("metrics.csv");
}

function MessageHtmlPreviewAttachmentComposition() {
  const [opening, setOpening] = useState(false);

  const handlePreview = useCallback(() => setOpening(true), []);

  return (
    <MessageAttachmentCard aria-busy={opening || undefined}>
      <MessageAttachmentTitle>screening.html</MessageAttachmentTitle>
      <MessageAttachmentMeta>
        <MessageAttachmentMetaStart>44 KB</MessageAttachmentMetaStart>
        <MessageAttachmentMetaEnd>
          {opening ? (
            <Spinner size="xs" />
          ) : (
            <MessageAttachmentAction
              aria-label="Preview screening.html"
              title="Preview screening.html"
              onClick={handlePreview}
            >
              <Eye aria-hidden />
            </MessageAttachmentAction>
          )}
        </MessageAttachmentMetaEnd>
      </MessageAttachmentMeta>
    </MessageAttachmentCard>
  );
}

function MessageFileAttachmentComposition() {
  return (
    <MessageAttachmentCard>
      <MessageAttachmentTitle>migration.diff</MessageAttachmentTitle>
      <MessageAttachmentSummary render={<MessageAttachmentDiffSummary />}>
        <span>2 files · 5 hunks</span>
        <MessageAttachmentDiffAddition>+42</MessageAttachmentDiffAddition>
        <MessageAttachmentDiffDeletion>-7</MessageAttachmentDiffDeletion>
      </MessageAttachmentSummary>
      <MessageAttachmentMeta>
        <MessageAttachmentMetaStart>18 KB</MessageAttachmentMetaStart>
        <MessageAttachmentMetaEnd>
          <MessageAttachmentAction
            aria-label="Download migration.diff"
            title="Download migration.diff"
            onClick={downloadMigrationDiff}
          >
            <ArrowDownToLine />
          </MessageAttachmentAction>
        </MessageAttachmentMetaEnd>
      </MessageAttachmentMeta>
    </MessageAttachmentCard>
  );
}

export type MessageAudioAttachmentCompositionProps = {
  meta?: ReactNode;
  name?: string;
  onDownload?: () => void;
  onPreview?: () => void;
};

export function MessageAudioAttachmentComposition({
  meta,
  name = "message-preview.wav",
  onDownload = () => downloadMessageFixture("message-preview.wav"),
  onPreview = () => window.open(MESSAGE_FIXTURE_AUDIO_SRC, "_blank", "noopener,noreferrer"),
}: MessageAudioAttachmentCompositionProps = {}) {
  return (
    <MessageAttachmentCard>
      <MessageAttachmentTitle>{name}</MessageAttachmentTitle>
      <MessageAttachmentMeta>
        {meta ? <MessageAttachmentMetaStart>{meta}</MessageAttachmentMetaStart> : null}
        <MessageAttachmentMetaEnd>
          <MessageAttachmentAction
            aria-label={`Preview ${name}`}
            onClick={onPreview}
            title={`Preview ${name}`}
          >
            <Eye aria-hidden />
          </MessageAttachmentAction>
          <MessageAttachmentAction
            aria-label={`Download ${name}`}
            onClick={onDownload}
            title={`Download ${name}`}
          >
            <ArrowDownToLine aria-hidden />
          </MessageAttachmentAction>
        </MessageAttachmentMetaEnd>
      </MessageAttachmentMeta>
    </MessageAttachmentCard>
  );
}

function MessageCommentedMarkdownAttachmentComposition() {
  return (
    <MessageAttachmentCard>
      <MessageAttachmentTitle>release-notes.md</MessageAttachmentTitle>
      <MessageAttachmentSummary render={<MessageAttachmentCommentSummary />}>
        <MessageSquare />6
      </MessageAttachmentSummary>
      <MessageAttachmentMeta>
        <MessageAttachmentMetaStart>1 KB</MessageAttachmentMetaStart>
        <MessageAttachmentMetaEnd>
          <MessageAttachmentAction
            aria-label="Download release-notes.md"
            title="Download release-notes.md"
            onClick={downloadReleaseNotes}
          >
            <ArrowDownToLine />
          </MessageAttachmentAction>
        </MessageAttachmentMetaEnd>
      </MessageAttachmentMeta>
    </MessageAttachmentCard>
  );
}

export function MessagePendingAttachmentComposition({
  name = "metrics.csv",
  label = "Processing…",
  meta,
  summary,
}: {
  name?: string;
  label?: ReactNode;
  meta?: ReactNode;
  summary?: ReactNode;
} = {}) {
  return (
    <MessageAttachmentCard aria-busy>
      <MessageAttachmentTitle>{name}</MessageAttachmentTitle>
      {summary ? <MessageAttachmentSummary>{summary}</MessageAttachmentSummary> : null}
      <MessageAttachmentMeta>
        <MessageAttachmentMetaStart>{meta ?? label}</MessageAttachmentMetaStart>
        <MessageAttachmentMetaEnd>
          <Spinner size="xs" />
        </MessageAttachmentMetaEnd>
      </MessageAttachmentMeta>
    </MessageAttachmentCard>
  );
}

function MessageCsvAttachmentComposition() {
  return (
    <MessageAttachmentCard>
      <MessageAttachmentTitle>metrics.csv</MessageAttachmentTitle>
      <MessageAttachmentSummary>24 rows · 6 columns</MessageAttachmentSummary>
      <MessageAttachmentMeta>
        <MessageAttachmentMetaStart>1 KB</MessageAttachmentMetaStart>
        <MessageAttachmentMetaEnd>
          <MessageAttachmentAction
            aria-label="Download metrics.csv"
            title="Download metrics.csv"
            onClick={downloadMetrics}
          >
            <ArrowDownToLine />
          </MessageAttachmentAction>
        </MessageAttachmentMetaEnd>
      </MessageAttachmentMeta>
    </MessageAttachmentCard>
  );
}

export function MessageGenericAttachmentComposition({
  name = "archive.zip",
  meta = "12 KB",
  onDownload = () => downloadMessageFixture("archive.zip"),
  onPreview,
}: {
  name?: string;
  meta?: ReactNode;
  onDownload?: (() => void) | null;
  onPreview?: () => void;
} = {}) {
  return (
    <MessageAttachmentCard>
      <MessageAttachmentTitle>{name}</MessageAttachmentTitle>
      <MessageAttachmentMeta>
        {meta ? <MessageAttachmentMetaStart>{meta}</MessageAttachmentMetaStart> : null}
        <MessageAttachmentMetaEnd>
          {onPreview ? (
            <MessageAttachmentAction
              aria-label={`Preview ${name}`}
              title={`Preview ${name}`}
              onClick={onPreview}
            >
              <Eye />
            </MessageAttachmentAction>
          ) : null}
          {onDownload ? (
            <MessageAttachmentAction
              aria-label={`Download ${name}`}
              title={`Download ${name}`}
              onClick={onDownload}
            >
              <ArrowDownToLine />
            </MessageAttachmentAction>
          ) : null}
        </MessageAttachmentMetaEnd>
      </MessageAttachmentMeta>
    </MessageAttachmentCard>
  );
}

export function MessageFileAttachmentsComposition() {
  return (
    <MessageAttachmentGroup>
      <MessageFileAttachmentComposition />
      <MessageHtmlPreviewAttachmentComposition />
      <MessageAudioAttachmentComposition meta="4 KB" />
      <MessageCommentedMarkdownAttachmentComposition />
      <MessageCsvAttachmentComposition />
      <MessageGenericAttachmentComposition />
    </MessageAttachmentGroup>
  );
}

export function MessageFileAttachmentStatesComposition() {
  return (
    <MessageAttachmentGroup>
      <MessageFileAttachmentComposition />
      <MessageHtmlPreviewAttachmentComposition />
      <MessageAudioAttachmentComposition meta="4 KB" />
      <MessageCommentedMarkdownAttachmentComposition />
      <MessagePendingAttachmentComposition
        name="metrics.csv"
        label="Processing…"
        meta="1 KB"
        summary="24 rows · 6 columns"
      />
      <MessageGenericAttachmentComposition />
    </MessageAttachmentGroup>
  );
}
```

```tsx
import { ArrowDownToLine } from "lucide-react";
import {
  MessageAttachmentAction,
  MessageAttachmentCard,
  MessageAttachmentGroup,
  MessageAttachmentMeta,
  MessageAttachmentMetaEnd,
  MessageAttachmentMetaStart,
  MessageAttachmentSummary,
  MessageAttachmentTitle,
} from "@botiverse/raft-ui";
import { downloadMessageFixture } from "#/modules/message-item/message-item.fixtures.ts";

function downloadMetrics() {
  downloadMessageFixture("metrics.csv");
}

function downloadReleaseNotes() {
  downloadMessageFixture("release-notes.md");
}

function downloadScreening() {
  downloadMessageFixture("screening.html");
}

function downloadArchitecture() {
  downloadMessageFixture("architecture.pdf");
}

export function MessageAttachmentDocumentSummariesComposition() {
  return (
    <MessageAttachmentGroup>
      <MessageAttachmentCard>
        <MessageAttachmentTitle>metrics.csv</MessageAttachmentTitle>
        <MessageAttachmentSummary>24 rows · 6 columns</MessageAttachmentSummary>
        <MessageAttachmentMeta>
          <MessageAttachmentMetaStart>1 KB</MessageAttachmentMetaStart>
          <MessageAttachmentMetaEnd>
            <MessageAttachmentAction
              aria-label="Download metrics.csv"
              title="Download metrics.csv"
              onClick={downloadMetrics}
            >
              <ArrowDownToLine />
            </MessageAttachmentAction>
          </MessageAttachmentMetaEnd>
        </MessageAttachmentMeta>
      </MessageAttachmentCard>

      <MessageAttachmentCard>
        <MessageAttachmentTitle>release-notes.md</MessageAttachmentTitle>
        <MessageAttachmentMeta>
          <MessageAttachmentMetaStart>1 KB</MessageAttachmentMetaStart>
          <MessageAttachmentMetaEnd>
            <MessageAttachmentAction
              aria-label="Download release-notes.md"
              title="Download release-notes.md"
              onClick={downloadReleaseNotes}
            >
              <ArrowDownToLine />
            </MessageAttachmentAction>
          </MessageAttachmentMetaEnd>
        </MessageAttachmentMeta>
      </MessageAttachmentCard>

      <MessageAttachmentCard>
        <MessageAttachmentTitle>screening.html</MessageAttachmentTitle>
        <MessageAttachmentMeta>
          <MessageAttachmentMetaStart>44 KB</MessageAttachmentMetaStart>
          <MessageAttachmentMetaEnd>
            <MessageAttachmentAction
              aria-label="Download screening.html"
              title="Download screening.html"
              onClick={downloadScreening}
            >
              <ArrowDownToLine />
            </MessageAttachmentAction>
          </MessageAttachmentMetaEnd>
        </MessageAttachmentMeta>
      </MessageAttachmentCard>

      <MessageAttachmentCard>
        <MessageAttachmentTitle>architecture.pdf</MessageAttachmentTitle>
        <MessageAttachmentMeta>
          <MessageAttachmentMetaStart>1.2 MB</MessageAttachmentMetaStart>
          <MessageAttachmentMetaEnd>
            <MessageAttachmentAction
              aria-label="Download architecture.pdf"
              title="Download architecture.pdf"
              onClick={downloadArchitecture}
            >
              <ArrowDownToLine />
            </MessageAttachmentAction>
          </MessageAttachmentMetaEnd>
        </MessageAttachmentMeta>
      </MessageAttachmentCard>
    </MessageAttachmentGroup>
  );
}
```

```tsx
import { ArrowDownToLine } from "lucide-react";
import {
  MessageAttachmentAction,
  MessageAttachmentCard,
  MessageAttachmentDiffAddition,
  MessageAttachmentDiffDeletion,
  MessageAttachmentDiffSummary,
  MessageAttachmentGroup,
  MessageAttachmentMeta,
  MessageAttachmentMetaEnd,
  MessageAttachmentMetaStart,
  MessageAttachmentSummary,
  MessageAttachmentTitle,
} from "@botiverse/raft-ui";
import { downloadMessageFixture } from "#/modules/message-item/message-item.fixtures.ts";

function downloadMigrationDiff() {
  downloadMessageFixture("migration.diff");
}

export function MessageAttachmentDiffUsageComposition() {
  return (
    <MessageAttachmentGroup>
      <MessageAttachmentCard>
        <MessageAttachmentTitle>migration.diff</MessageAttachmentTitle>
        <MessageAttachmentSummary render={<MessageAttachmentDiffSummary />}>
          <span>2 files · 5 hunks</span>
          <MessageAttachmentDiffAddition>+42</MessageAttachmentDiffAddition>
          <MessageAttachmentDiffDeletion>-7</MessageAttachmentDiffDeletion>
        </MessageAttachmentSummary>
        <MessageAttachmentMeta>
          <MessageAttachmentMetaStart>18 KB</MessageAttachmentMetaStart>
          <MessageAttachmentMetaEnd>
            <MessageAttachmentAction
              aria-label="Download migration.diff"
              onClick={downloadMigrationDiff}
              title="Download migration.diff"
            >
              <ArrowDownToLine />
            </MessageAttachmentAction>
          </MessageAttachmentMetaEnd>
        </MessageAttachmentMeta>
      </MessageAttachmentCard>
    </MessageAttachmentGroup>
  );
}
```

| Images    | Layout                                |
| --------- | ------------------------------------- |
| 1         | Keeps its own proportions             |
| 2 or more | Rows laid out from the images' shapes |

```tsx
import { Download } from "lucide-react";
import {
  MessageImageGallery,
  MessageImageGalleryAction,
  MessageImageGalleryItem,
  MessageImageGalleryMedia,
  MessageImageGalleryPreview,
} from "@botiverse/raft-ui";
import {
  downloadMessageFixture,
  MESSAGE_FIXTURE_IMAGE_SRC,
} from "#/modules/message-item/message-item.fixtures.ts";

const SINGLE_ITEM_STYLE = { width: "min(288px, 100%)", aspectRatio: "4 / 3" };

function downloadImage() {
  downloadMessageFixture("message-image.jpg");
}

export type MessageImageGallerySingleCompositionProps = {
  alt?: string;
  name?: string;
  onDownload?: () => void;
  onPreview?: () => void;
  src?: string;
};

export function MessageImageGallerySingleComposition({
  alt = "attachment preview",
  name = "message-image.jpg",
  onDownload = downloadImage,
  onPreview,
  src = MESSAGE_FIXTURE_IMAGE_SRC,
}: MessageImageGallerySingleCompositionProps = {}) {
  return (
    <MessageImageGallery>
      <div className="grid grid-cols-1 gap-2">
        <MessageImageGalleryItem
          className="inline-block w-fit max-w-[416px] justify-self-start"
          style={SINGLE_ITEM_STYLE}
        >
          <MessageImageGalleryMedia>
            <img src={src} alt={alt} className="block size-full  object-contain" />
          </MessageImageGalleryMedia>
          {onPreview ? (
            <MessageImageGalleryPreview
              aria-label={`Preview ${name}`}
              onClick={onPreview}
              title={`Preview ${name}`}
            />
          ) : null}
          <MessageImageGalleryAction
            aria-label={`Download ${name}`}
            onClick={onDownload}
            title={`Download ${name}`}
          >
            <Download />
          </MessageImageGalleryAction>
        </MessageImageGalleryItem>
      </div>
    </MessageImageGallery>
  );
}
```

```tsx
import { Download } from "lucide-react";
import {
  MessageImageGallery,
  MessageImageGalleryAction,
  MessageImageGalleryItem,
  MessageImageGalleryMedia,
  MessageImageGalleryPreview,
} from "@botiverse/raft-ui";
import {
  downloadMessageFixture,
  MESSAGE_FIXTURE_IMAGE_SRC,
  openMessageFixture,
} from "#/modules/message-item/message-item.fixtures.ts";

function downloadImage() {
  downloadMessageFixture("message-image.jpg");
}

export function MessageImageGalleryPairComposition() {
  return (
    <MessageImageGallery>
      <div className="grid grid-cols-2 gap-2">
        <MessageImageGalleryItem className="h-[128px] sm:h-[144px]">
          <MessageImageGalleryMedia>
            <img
              src={MESSAGE_FIXTURE_IMAGE_SRC}
              alt="release poster"
              className="block size-full  object-cover"
            />
          </MessageImageGalleryMedia>
          <MessageImageGalleryPreview aria-label="Preview image" onClick={openMessageFixture} />
          <MessageImageGalleryAction aria-label="Download image" onClick={downloadImage}>
            <Download />
          </MessageImageGalleryAction>
        </MessageImageGalleryItem>
        <MessageImageGalleryItem className="h-[128px] sm:h-[144px]">
          <MessageImageGalleryMedia>
            <img
              src={MESSAGE_FIXTURE_IMAGE_SRC}
              alt="release poster detail"
              className="block size-full  object-cover"
            />
          </MessageImageGalleryMedia>
          <MessageImageGalleryPreview aria-label="Preview image" onClick={openMessageFixture} />
          <MessageImageGalleryAction aria-label="Download image" onClick={downloadImage}>
            <Download />
          </MessageImageGalleryAction>
        </MessageImageGalleryItem>
      </div>
    </MessageImageGallery>
  );
}
```

```tsx
import {
  MessageImageGallery,
  MessageImageGalleryItem,
  MessageImageGalleryMedia,
} from "@botiverse/raft-ui";
import { MESSAGE_FIXTURE_IMAGE_SRC } from "#/modules/message-item/message-item.fixtures.ts";

export function MessageImageGalleryTripleComposition() {
  return (
    <MessageImageGallery>
      <div className="grid grid-cols-2 gap-2 md:grid-cols-3">
        <MessageImageGalleryItem className="h-[112px] sm:h-[128px]">
          <MessageImageGalleryMedia>
            <img
              src={MESSAGE_FIXTURE_IMAGE_SRC}
              alt="release poster first crop"
              className="block size-full  object-cover"
            />
          </MessageImageGalleryMedia>
        </MessageImageGalleryItem>
        <MessageImageGalleryItem className="h-[112px] sm:h-[128px]">
          <MessageImageGalleryMedia>
            <img
              src={MESSAGE_FIXTURE_IMAGE_SRC}
              alt="release poster second crop"
              className="block size-full  object-cover"
            />
          </MessageImageGalleryMedia>
        </MessageImageGalleryItem>
        <MessageImageGalleryItem className="h-[112px] sm:h-[128px]">
          <MessageImageGalleryMedia>
            <img
              src={MESSAGE_FIXTURE_IMAGE_SRC}
              alt="release poster third crop"
              className="block size-full  object-cover"
            />
          </MessageImageGalleryMedia>
        </MessageImageGalleryItem>
      </div>
    </MessageImageGallery>
  );
}
```

## States

| State                 | Look                                                                                                                                                                                                                                |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Uploading             | The meta line says "Uploading · 64%", and a 4px progress track runs under the file name, inside the card and aligned with the text ([Shape](https://raft-design.botiverse.build/foundations/shape.md#nothing-on-the-edge-of-a-rounded-container)) |
| Opening or processing | `aria-busy` on the card, and a [Spinner](https://raft-design.botiverse.build/components/spinner.md) in place of the action                                                                                                                                              |
| Pending image         | The image dimmed under `MessageImageGalleryOverlay`                                                                                                                                                                                 |
| Failed                | A `danger-strong` border, a line that says why, and Remove or Retry                                                                                                                                                                 |

```tsx
import { ArrowDownToLine, MessageSquare } from "lucide-react";
import { Spinner } from "@botiverse/raft-ui";
import {
  MessageAttachmentAction,
  MessageAttachmentCommentSummary,
  MessageAttachmentCard,
  MessageAttachmentGroup,
  MessageAttachmentMeta,
  MessageAttachmentMetaEnd,
  MessageAttachmentMetaStart,
  MessageAttachmentSummary,
  MessageAttachmentTitle,
} from "@botiverse/raft-ui";
import { downloadMessageFixture } from "#/modules/message-item/message-item.fixtures.ts";

function downloadReleaseNotes() {
  downloadMessageFixture("release-notes.md");
}

export function MessageAttachmentStatusesUsageComposition() {
  return (
    <MessageAttachmentGroup>
      <MessageAttachmentCard>
        <MessageAttachmentTitle>release-notes.md</MessageAttachmentTitle>
        <MessageAttachmentSummary render={<MessageAttachmentCommentSummary />}>
          <MessageSquare />6
        </MessageAttachmentSummary>
        <MessageAttachmentMeta>
          <MessageAttachmentMetaStart>1 KB</MessageAttachmentMetaStart>
          <MessageAttachmentMetaEnd>
            <MessageAttachmentAction
              aria-label="Download release-notes.md"
              onClick={downloadReleaseNotes}
              title="Download release-notes.md"
            >
              <ArrowDownToLine />
            </MessageAttachmentAction>
          </MessageAttachmentMetaEnd>
        </MessageAttachmentMeta>
      </MessageAttachmentCard>

      <MessageAttachmentCard aria-busy>
        <MessageAttachmentTitle>metrics.csv</MessageAttachmentTitle>
        <MessageAttachmentMeta>
          <MessageAttachmentMetaStart>1 KB</MessageAttachmentMetaStart>
          <MessageAttachmentMetaEnd>
            <Spinner size="xs" />
          </MessageAttachmentMetaEnd>
        </MessageAttachmentMeta>
      </MessageAttachmentCard>
    </MessageAttachmentGroup>
  );
}
```

```tsx
import { Spinner } from "@botiverse/raft-ui";
import {
  MessageImageGallery,
  MessageImageGalleryItem,
  MessageImageGalleryMedia,
  MessageImageGalleryOverlay,
} from "@botiverse/raft-ui";
import { MESSAGE_FIXTURE_IMAGE_SRC } from "#/modules/message-item/message-item.fixtures.ts";

const PENDING_ITEM_STYLE = { width: "min(288px, 100%)", aspectRatio: "4 / 3" };

export function MessageImageGalleryPendingComposition() {
  return (
    <MessageImageGallery>
      <div className="grid grid-cols-1 gap-2">
        <MessageImageGalleryItem
          aria-busy
          className="inline-block w-fit max-w-[416px] justify-self-start"
          style={PENDING_ITEM_STYLE}
        >
          <MessageImageGalleryMedia>
            <img
              src={MESSAGE_FIXTURE_IMAGE_SRC}
              alt="attachment preview uploading"
              className="block size-full  object-contain"
            />
          </MessageImageGalleryMedia>
          <MessageImageGalleryOverlay>
            <Spinner size="md" variant="inverse" />
          </MessageImageGalleryOverlay>
        </MessageImageGalleryItem>
      </div>
    </MessageImageGallery>
  );
}
```

```tsx
import { AlertTriangle, ArrowDownToLine } from "lucide-react";
import { Spinner } from "@botiverse/raft-ui";
import {
  MessageVideoPreview,
  MessageVideoPreviewAction,
  MessageVideoPreviewFailureAction,
  MessageVideoPreviewFailureContent,
  MessageVideoPreviewFailureIcon,
  MessageVideoPreviewFailureTitle,
  MessageVideoPreviewFallback,
  MessageVideoPreviewMedia,
} from "@botiverse/raft-ui";
import {
  MESSAGE_FIXTURE_VIDEO_SRC,
  downloadMessageFixture,
} from "#/modules/message-item/message-item.fixtures.ts";

export type MessageVideoPreviewCompositionProps = {
  name?: string;
  onDownload?: () => void;
  src?: string;
};

export function MessageVideoPreviewComposition({
  name = "demo-recording.webm",
  onDownload = () => downloadMessageFixture("demo-recording.webm"),
  src = MESSAGE_FIXTURE_VIDEO_SRC,
}: MessageVideoPreviewCompositionProps = {}) {
  return (
    <MessageVideoPreview title={name}>
      <MessageVideoPreviewMedia
        aria-label={`Play ${name}`}
        controls
        playsInline
        preload="metadata"
        src={src}
      />
      <MessageVideoPreviewAction
        aria-label={`Download ${name}`}
        title={`Download ${name}`}
        onClick={onDownload}
      >
        <ArrowDownToLine />
      </MessageVideoPreviewAction>
    </MessageVideoPreview>
  );
}

function MessageVideoPendingComposition() {
  return (
    <MessageVideoPreview aria-busy title="demo-recording.mp4">
      <MessageVideoPreviewFallback>
        <Spinner size="md" variant="inverse" />
      </MessageVideoPreviewFallback>
    </MessageVideoPreview>
  );
}

export function MessageVideoPreviewFailedComposition({
  name = "demo-recording.webm",
  reason = "This video may use a codec this browser cannot preview.",
  onDownload = () => downloadMessageFixture("demo-recording.webm"),
}: {
  name?: string;
  reason?: string;
  onDownload?: (() => void) | null;
} = {}) {
  return (
    <MessageVideoPreview title={name}>
      <MessageVideoPreviewFallback>
        <MessageVideoPreviewFailureContent>
          <MessageVideoPreviewFailureIcon>
            <AlertTriangle />
          </MessageVideoPreviewFailureIcon>
          <MessageVideoPreviewFailureTitle>{reason}</MessageVideoPreviewFailureTitle>
          {onDownload ? (
            <MessageVideoPreviewFailureAction onClick={onDownload}>
              <ArrowDownToLine />
              Download to view
            </MessageVideoPreviewFailureAction>
          ) : null}
        </MessageVideoPreviewFailureContent>
      </MessageVideoPreviewFallback>
    </MessageVideoPreview>
  );
}

export function MessageVideoPreviewStatesComposition() {
  return (
    <div className="stack-y-2">
      <MessageVideoPreviewComposition />
      <MessageVideoPendingComposition />
      <MessageVideoPreviewFailedComposition />
    </div>
  );
}
```

## Behavior

- The whole card is clickable: the file name is the link, and it covers the card. The action buttons sit above it and work on their own.
- The card is read, so it hovers with the subtle tier, like a file row or a comment card, and presses with the medium tier; its action buttons hover medium ([Hover and press](https://raft-design.botiverse.build/foundations/states.md#hover-and-press)).
- Selecting an image or a file opens it in the file viewer ([Built from existing parts](https://raft-design.botiverse.build/components.md#file-viewer)).
- The gallery's action button shows on hover and on keyboard focus.
- An action in a card's or a medium's corner (a file card's Download, a video's or an image's action) is a control embedded in its frame: it sits 6px from the corner and wears `radius-chip` ([Shape](https://raft-design.botiverse.build/foundations/shape.md#inside-a-frame)).

## Content

- Show the file name as it is, on one line, truncated when it doesn't fit.
- Write the size and type in Space Mono: "2.4 MB · PDF".
- In a failure, say why and give the limit: "Larger than 25 MB. Remove it, or compress it and try again." Label the buttons Remove and Retry.
- Say what is happening while a file is busy, such as "Uploading · 64%"; don't write "Processing…" ([Writing](https://raft-design.botiverse.build/content/writing.md#do-and-dont)).

## Accessibility

- Don't put a button inside the file name's `<a>`: lay the actions over the link instead, so each is its own control.
- Give every `MessageAttachmentAction` an `aria-label` with the action and the file name: "Download release-notes.pdf".
- Give the upload progress `role="progressbar"`, labeled with the file it uploads.
- Set `aria-busy` on a card while it uploads or opens.
- Give every image an `alt` that says what it shows. Give `MessageImageGalleryPreview` an `aria-label`, such as "Open onboarding-step-2.png".

## Rules

- Keep a file card at most 360px wide.
- Show images at most 360px wide, with `radius-container` and a `line-hairline` border.

## API

### MessageAttachmentAction

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageAttachmentCard

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageAttachmentCommentSummary

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageAttachmentDiffAddition

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageAttachmentDiffDeletion

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageAttachmentDiffSummary

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageAttachmentGroup

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageAttachmentMeta

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageAttachmentMetaEnd

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageAttachmentMetaStart

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageAttachmentSummary

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageAttachmentTitle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageImageGallery

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageImageGalleryAction

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageImageGalleryItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `layout` | `MessageImageGalleryItemLayout` |  | The item's layout from `buildMessageImageGalleryRows`: sets the cell sizing and how the media fills it, so consumers never restate classes. |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageImageGalleryMedia

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageImageGalleryOverlay

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageImageGalleryPreview

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageImageGalleryRow

One row of a gallery: the grid and its height come from the row data, so consumers place the items (`MessageImageGalleryItem` with the row's item layouts) without touching layout classes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `row` (required) | `MessageImageGalleryRowData` |  | A row from `buildMessageImageGalleryRows`. |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageVideoPreview

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageVideoPreviewAction

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageVideoPreviewFallback

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageVideoPreviewFailureAction

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageVideoPreviewFailureContent

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageVideoPreviewFailureIcon

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageVideoPreviewFailureTitle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### MessageVideoPreviewMedia

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.
