# Attachment previews

The composer shows what a message will carry above the input: each attachment with its upload progress, a voice recording, link previews and a location. This recipe replaces those previews, one kind at a time. How attachments are added and uploaded is explained in [Attachments and uploads](https://getstream.io/chat/docs/sdk/react/features/rich-content/attachments-and-uploads/), and where the previews sit in [Message composer UI](https://getstream.io/chat/docs/sdk/react/ui/components/message-composer-ui/#previews).

>
> **Note:** **What you'll build**
>
> - Square thumbnails for image attachments
> - Upload progress as a percentage
> - A preview chip for an event attachment of your own
> - A compact voice recording preview
>

---

**Preview**

![The composer with image attachments shown as small square thumbnails, one of them still uploading](https://getstream.io/docs-assets/images/4d6c7d0d2527.png)

---

**Approach:** Replace one part. [Choosing where to start](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/overview/#choosing-where-to-start) explains the approaches.

**Starts from:** [the base app](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/overview/#the-base-app). The page shows only what the recipe adds.

## Best practices

- Replace one kind of preview through the props of `AttachmentPreviewList`, and register the wrapped list under the `AttachmentPreviewList` key. The other kinds keep their defaults.
- To change only the upload progress or the file sizes, override the small parts first: `ProgressIndicator`, `UploadedSizeIndicator` and `FileSizeIndicator`. They apply to every preview and to message attachments.
- Keep the ✕ and the retry button working in a preview of your own: call `removeAttachments` and `handleRetry`, which every preview receives.
- Render attachment types of your own in `UnsupportedAttachmentPreview`, and fall back to the default for the rest.
- Replace voice recording previews through `VoiceRecordingPreviewSlot`. They aren't part of `AttachmentPreviewList`.
- Keep previews compact, so they don't push the input out of view.

## Default attachment preview types

`AttachmentPreviewList` renders one preview per attachment, picked by the attachment's type. Each component is a prop of the list:

| Attachment                            | Prop                           | Default                                                                    |
| ------------------------------------- | ------------------------------ | -------------------------------------------------------------------------- |
| An image                              | `ImageAttachmentPreview`       | A thumbnail with the progress ring over it. Clicking it opens the gallery. |
| A video                               | `VideoAttachmentPreview`       | The same as an image                                                       |
| An audio file                         | `AudioAttachmentPreview`       | The same as a file                                                         |
| Any other file                        | `FileAttachmentPreview`        | A row with the file's icon, name, progress ring and size                   |
| An attachment of a type your app adds | `UnsupportedAttachmentPreview` | A row with a generic icon and the ✕                                        |

Three previews are rendered outside the list:

- Voice recordings, by `VoiceRecordingPreviewSlot`, above the list. It shows the first recording.
- Link previews, by `LinkPreviewList`. See [Link previews](https://getstream.io/chat/docs/sdk/react/features/rich-content/link-previews/).
- The location to share, by `<GeolocationPreview/>`. It has no override key of its own: to show it differently, replace the `location` item of the composer's preview set, as [Custom previews](https://getstream.io/chat/docs/sdk/react/ui/components/message-composer-ui/#custom-previews) describes.

Every preview receives the attachment and two functions:

- `removeAttachments(ids)` removes attachments from the composer. Removing one that is still uploading cancels its upload.
- `handleRetry(attachment)` uploads a failed attachment again.

Image and video previews also receive `openPreview()`, which opens the gallery. The upload state is in `attachment.localMetadata`: `uploadState` is `pending`, `uploading`, `finished`, `failed` or `blocked`, and `uploadProgress` goes from 0 to 100.

## Customize default attachment previews

Of the defaults in the table, only `UnsupportedAttachmentPreview` is exported. A replacement for the image, video, audio or file preview renders the whole preview. This one shows images as small square thumbnails:

```tsx label="React" title="src/ImageAttachmentPreview.tsx"
import { isUploadConfirmationPending } from "stream-chat";
import type { ImageAttachmentPreviewProps } from "stream-chat-react";
import {
  RemoveAttachmentPreviewButton,
  UploadProgressIndicator,
  useComponentContextIcons,
} from "stream-chat-react";

export const ImageAttachmentPreview = ({
  attachment,
  handleRetry,
  openPreview,
  removeAttachments,
}: ImageAttachmentPreviewProps) => {
  const { IconExclamationMark } = useComponentContextIcons();
  const { id, previewUri, uploadPermissionCheck, uploadProgress, uploadState } =
    attachment.localMetadata;
  // The local file until the upload finishes, then the uploaded image.
  const src = attachment.image_url || previewUri;
  // Blocked, or over the size limit: retrying can't help, so the preview only shows the error.
  const cannotUpload =
    uploadState === "blocked" || uploadPermissionCheck?.reason === "size_limit";
  const canRetry = uploadState === "failed" && !cannotUpload;

  return (
    <div className="cookbook-image-preview">
      <button
        aria-label="Open image"
        className="cookbook-image-preview__thumbnail"
        disabled={uploadState !== "finished" || cannotUpload}
        onClick={openPreview}
        type="button"
      >
        {src && <img alt="" src={src} />}
      </button>
      {uploadState === "uploading" && (
        <div className="cookbook-image-preview__progress">
          <UploadProgressIndicator
            // True while the file is sent but the server hasn't confirmed it: a spinner, not 100%.
            uploadConfirmationPending={isUploadConfirmationPending(
              attachment.localMetadata,
            )}
            uploadProgress={uploadProgress}
          />
        </div>
      )}
      {cannotUpload && (
        <div
          aria-label="Can't upload"
          className="cookbook-image-preview__error"
          role="img"
        >
          <IconExclamationMark />
        </div>
      )}
      {canRetry && (
        <button
          className="cookbook-image-preview__retry"
          onClick={() => handleRetry(attachment)}
          type="button"
        >
          Retry
        </button>
      )}
      <RemoveAttachmentPreviewButton
        onClick={() => removeAttachments([id])}
        uploadState={uploadState}
      />
    </div>
  );
};
```

```css label="CSS" title="src/ImageAttachmentPreview.css"
@layer stream-overrides {
  .cookbook-image-preview {
    position: relative;
  }

  .cookbook-image-preview__thumbnail {
    overflow: hidden;
    width: 48px;
    height: 48px;
    padding: 0;
    border: 1px solid var(--str-chat__border-core-default);
    border-radius: var(--str-chat__radius-lg);
    background: var(--str-chat__background-core-surface-subtle);
    cursor: pointer;
  }

  .cookbook-image-preview__thumbnail:disabled {
    cursor: default;
  }

  .cookbook-image-preview__thumbnail img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  /* The progress ring sits over the thumbnail. */
  .cookbook-image-preview__progress {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--str-chat__radius-lg);
    background: var(--str-chat__background-core-overlay-light);
    pointer-events: none;
  }

  /* A file that can't be uploaded shows the error mark over a dimmed thumbnail. */
  .cookbook-image-preview__error {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 1px solid var(--str-chat__border-utility-error);
    border-radius: var(--str-chat__radius-lg);
    background: var(--str-chat__background-core-overlay-light);
    color: var(--str-chat__accent-error);
    pointer-events: none;
  }

  .cookbook-image-preview__retry {
    position: absolute;
    inset-inline: 0;
    bottom: 0;
    padding: var(--str-chat__spacing-xxs);
    border: 0;
    border-radius: var(--str-chat__radius-max);
    background: var(--str-chat__accent-error);
    color: var(--str-chat__text-on-accent);
    font: var(--str-chat__font-caption-emphasis);
    cursor: pointer;
  }
}
```

The rules sit in the `stream-overrides` layer, so they win over the SDK's own rules once the SDK's stylesheet is in an earlier layer, as [Theming → CSS layers](https://getstream.io/chat/docs/sdk/react/features/app-experience/theming/#css-layers) sets up. They take their colors, spacing and type from the SDK's tokens, so they follow your theme.

`RemoveAttachmentPreviewButton` is the SDK's ✕. It places itself on the top corner of the nearest positioned element, which is why the preview's root is `position: relative`. Its label says "Cancel upload" while the file is uploading, and "Remove attachment" after. `UploadProgressIndicator` shows the progress ring, or a spinner while the progress is unknown or the file is sent but `uploadConfirmationPending` says the server hasn't confirmed it yet; `isUploadConfirmationPending(attachment.localMetadata)` from `stream-chat` works that out, as the SDK's own previews do.

A file can also be refused before it uploads: `uploadState` is `blocked`, or `localMetadata.uploadPermissionCheck.reason` is `size_limit` for a file over the app's size limit. Retrying can't help either, so the preview shows the SDK's error mark over the thumbnail, keeps the gallery closed and offers only the ✕. A `failed` upload shows the retry button.

Pass the preview to `AttachmentPreviewList`, and register the list under the `AttachmentPreviewList` key:

```tsx title="src/WithSquareImagePreviews.tsx"
import type { PropsWithChildren } from "react";
import { AttachmentPreviewList, WithComponents } from "stream-chat-react";
import { ImageAttachmentPreview } from "./ImageAttachmentPreview";
import "./ImageAttachmentPreview.css";

const CustomAttachmentPreviewList = () => (
  <AttachmentPreviewList ImageAttachmentPreview={ImageAttachmentPreview} />
);

// Declared once, so WithComponents gets the same object on every render.
const overrides = { AttachmentPreviewList: CustomAttachmentPreviewList };

export const WithSquareImagePreviews = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);
```

Wrap `ChatView`, or a single `Channel`, in `WithSquareImagePreviews`.

The props types of the image, file and voice recording previews are exported: `ImageAttachmentPreviewProps`, `FileAttachmentPreviewProps` and `VoiceRecordingPreviewProps`. Those of the video and audio previews aren't.

## Upload progress and size UI

The default previews build their upload UI from small parts, each with an override key:

| Override key            | Shows                                                                               | Receives                     |
| ----------------------- | ----------------------------------------------------------------------------------- | ---------------------------- |
| `ProgressIndicator`     | The progress ring while a file uploads                                              | `percent`, from 0 to 100     |
| `UploadedSizeIndicator` | "1.2 MB / 3 MB" on a file row while it uploads                                      | `uploadedBytes`, `fullBytes` |
| `FileSizeIndicator`     | A file's size, on a file row once it's uploaded and on file attachments in messages | `fileSize`                   |

While the progress is unknown, or every byte is sent and the server hasn't answered yet, the previews show `LoadingIndicator` instead of the ring. That key also replaces the SDK's other spinners.

The same parts show the progress on message attachments when [sending with pending uploads](https://getstream.io/chat/docs/sdk/react/features/messaging/sending-with-pending-uploads/) is turned on, so an attachment looks the same before and after the user presses send. To replace that whole block on message attachments, the ring and the sizes together, override `AttachmentUploadProgressIndicator`. It receives the `attachment`, or a gallery's `attachments`.

This override shows the progress as a percentage:

```tsx label="React" title="src/UploadPercent.tsx"
import type { PropsWithChildren } from "react";
import type { ProgressIndicatorProps } from "stream-chat-react";
import { WithComponents } from "stream-chat-react";
import "./UploadPercent.css";

const UploadPercent = ({ percent }: ProgressIndicatorProps) => (
  <span className="cookbook-upload-percent">{Math.round(percent)}%</span>
);

const overrides = { ProgressIndicator: UploadPercent };

export const WithUploadPercent = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);
```

```css label="CSS" title="src/UploadPercent.css"
@layer stream-overrides {
  .cookbook-upload-percent {
    padding: var(--str-chat__spacing-xxs) var(--str-chat__spacing-xs);
    border-radius: var(--str-chat__radius-max);
    background: var(--str-chat__background-core-elevation-1);
    color: var(--str-chat__text-primary);
    font: var(--str-chat__font-caption-emphasis);
  }
}
```

---

**Upload progress as a percentage**

![The default image previews with the upload progress shown as 60% on the one still uploading](https://getstream.io/docs-assets/images/5f9ff3d05200.png)

---

## Customize custom attachment previews

An attachment your app adds to the composer, for example with `AttachmentManager.upsertAttachments()`, has a type the list doesn't know. It goes to `UnsupportedAttachmentPreview`. Branch on your own type there, and render the default for everything else:

```tsx label="React" title="src/CustomAttachmentPreviewList.tsx"
import type { PropsWithChildren } from "react";
import type { UnsupportedAttachmentPreviewProps } from "stream-chat-react";
import {
  AttachmentPreviewList,
  UnsupportedAttachmentPreview,
  useComponentContextIcons,
  WithComponents,
} from "stream-chat-react";
import "./CustomAttachmentPreviewList.css";

const EventOrUnsupportedPreview = (
  props: UnsupportedAttachmentPreviewProps,
) => {
  const { IconXmarkSmall } = useComponentContextIcons();
  const { attachment, removeAttachments } = props;

  if (attachment.type !== "event") {
    return <UnsupportedAttachmentPreview {...props} />;
  }

  return (
    <div className="cookbook-event-preview">
      <span className="cookbook-event-preview__title">
        📅 {attachment.title}
      </span>
      <button
        aria-label="Remove event"
        className="cookbook-event-preview__remove"
        onClick={() => removeAttachments([attachment.localMetadata.id])}
        type="button"
      >
        <IconXmarkSmall />
      </button>
    </div>
  );
};

export const CustomAttachmentPreviewList = () => (
  <AttachmentPreviewList
    UnsupportedAttachmentPreview={EventOrUnsupportedPreview}
  />
);

const overrides = { AttachmentPreviewList: CustomAttachmentPreviewList };

export const WithEventPreviews = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);
```

```css label="CSS" title="src/CustomAttachmentPreviewList.css"
@layer stream-overrides {
  .cookbook-event-preview {
    display: flex;
    align-items: center;
    gap: var(--str-chat__spacing-xs);
    padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm);
    border: 1px solid var(--str-chat__border-core-default);
    border-radius: var(--str-chat__radius-max);
    background: var(--str-chat__background-core-surface-card);
    color: var(--str-chat__text-primary);
    font: var(--str-chat__font-caption-emphasis);
  }

  .cookbook-event-preview__remove {
    display: flex;
    padding: 0;
    border: 0;
    background: none;
    color: var(--str-chat__text-secondary);
    cursor: pointer;
  }
}
```

---

**An event attachment chip**

![An event attachment shown as a chip with a calendar emoji, its title and a remove button](https://getstream.io/docs-assets/images/288ea3ff4110.png)

---

`WithEventPreviews` registers the list under the `AttachmentPreviewList` key, as in the image example. An attachment added with `AttachmentManager.upsertAttachments()` needs a unique `localMetadata.id`: the preview removes it by that id. To render the same attachment in the message list, see [Change how attachments render](https://getstream.io/chat/docs/sdk/react/features/rich-content/attachments-and-uploads/#change-how-attachments-render).

## Customize voice recording previews

A voice recording waiting to be sent shows in `VoiceRecordingPreviewSlot`, above the other previews. Give it your own `VoiceRecordingPreview`:

```tsx label="React" title="src/VoiceRecordingPreview.tsx"
import type { PropsWithChildren } from "react";
import type { VoiceRecordingPreviewProps } from "stream-chat-react";
import {
  RemoveAttachmentPreviewButton,
  VoiceRecordingPreviewSlot,
  WithComponents,
} from "stream-chat-react";
import "./VoiceRecordingPreview.css";

const CompactVoiceRecordingPreview = ({
  attachment,
  removeAttachments,
}: VoiceRecordingPreviewProps) => {
  const { id, previewUri, uploadState } = attachment.localMetadata;

  return (
    <div className="cookbook-voice-preview">
      <audio controls src={attachment.asset_url || previewUri} />
      <RemoveAttachmentPreviewButton
        onClick={() => removeAttachments([id])}
        uploadState={uploadState}
      />
    </div>
  );
};

const CustomVoiceRecordingPreviewSlot = () => (
  <VoiceRecordingPreviewSlot
    VoiceRecordingPreview={CompactVoiceRecordingPreview}
  />
);

const overrides = {
  VoiceRecordingPreviewSlot: CustomVoiceRecordingPreviewSlot,
};

export const WithVoicePreview = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);
```

```css label="CSS" title="src/VoiceRecordingPreview.css"
@layer stream-overrides {
  .cookbook-voice-preview {
    position: relative;
    width: fit-content;
    padding: var(--str-chat__spacing-xxs);
    border: 1px solid var(--str-chat__border-core-default);
    border-radius: var(--str-chat__radius-max);
    background: var(--str-chat__background-core-surface-subtle);
  }

  .cookbook-voice-preview audio {
    height: 32px;
  }
}
```

---

**A voice recording preview**

![A voice recording waiting to be sent, shown as the browser's audio player with a remove button](https://getstream.io/docs-assets/images/5d07907a5473.png)

---

Wrap `ChatView` in `WithVoicePreview`. Voice recording is off until `MessageComposer` gets `audioRecordingEnabled`: `<MessageComposer audioRecordingEnabled />`. [Voice recordings and audio playback](https://getstream.io/chat/docs/sdk/react/features/rich-content/voice-recordings-and-audio-playback/) covers recording and the players.

## How it works

>
> **Info:** **Example**
>
> The user adds two photos to the composer in Weekend trip, with `WithSquareImagePreviews` around `ChatView`:
>
> 1. The composer adds an attachment for each photo. `AttachmentPreviewList` renders one preview per attachment, and picks `ImageAttachmentPreview` for images.
> 2. While a photo uploads, `attachment.localMetadata.uploadState` is `uploading`. The thumbnail shows the local file, `previewUri`, and `UploadProgressIndicator` shows the progress ring over it.
> 3. When the upload finishes, `uploadState` is `finished` and the thumbnail shows `attachment.image_url`. Clicking it calls `openPreview()`, which opens the gallery.
> 4. When an upload fails, `uploadState` is `failed`, and Retry calls `handleRetry(attachment)`. The ✕ calls `removeAttachments([id])`; on a photo that is still uploading, it cancels the upload.
>

Files, videos, voice recordings and link previews keep their default previews.

## Related

- [Attachments and uploads](https://getstream.io/chat/docs/sdk/react/features/rich-content/attachments-and-uploads/): how attachments are added, uploaded and rendered.
- [Attachments](https://getstream.io/chat/docs/sdk/react/state/writing-messages/attachments/): the composer's attachment manager and the upload state.
- [Message composer UI](https://getstream.io/chat/docs/sdk/react/ui/components/message-composer-ui/#previews): where the previews sit in the composer.
- [Sending with pending uploads](https://getstream.io/chat/docs/sdk/react/features/messaging/sending-with-pending-uploads/): the same upload parts on message attachments.
- [Voice recordings and audio playback](https://getstream.io/chat/docs/sdk/react/features/rich-content/voice-recordings-and-audio-playback/): recording and the players.
- [Overriding components](https://getstream.io/chat/docs/sdk/react/ui/components/overriding-components/): how overrides registered with `WithComponents` apply.
- [Custom composer](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/composer/custom-composer/): a composer layout of your own, which can show the location preview differently.
- [Product card](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/custom-attachments/product-card/): a preview for an attachment type of your own.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/ui-cookbooks/composer/attachment-previews/](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/composer/attachment-previews/).